Skip to content

♻️ feat(workflow_engine): issue alert action migration util for slack #83123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
136 changes: 136 additions & 0 deletions src/sentry/workflow_engine/migration_helpers/rule_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import logging
from typing import Any

from sentry.notifications.models.notificationaction import ActionTarget
from sentry.workflow_engine.models.action import Action
from sentry.workflow_engine.typings.notification_action import (
ACTION_TYPE_TO_INTEGRATION_ID_KEY,
ACTION_TYPE_TO_TARGET_DISPLAY_KEY,
ACTION_TYPE_TO_TARGET_IDENTIFIER_KEY,
ACTION_TYPE_TO_TARGET_TYPE_RULE_REGISTRY,
EXCLUDED_ACTION_DATA_KEYS,
RULE_REGISTRY_ID_TO_INTEGRATION_PROVIDER,
SlackDataBlob,
)

_logger = logging.getLogger(__name__)


def build_slack_data_blob(action: dict[str, Any]) -> SlackDataBlob:
"""
Builds a SlackDataBlob from the action data.
Only includes the keys that are not None.
"""
return SlackDataBlob(
tags=action.get("tags", ""),
notes=action.get("notes", ""),
)


def sanitize_action(action: dict[str, Any], action_type: Action.Type) -> dict[str, Any]:
"""
Pops the keys we don't want to save inside the JSON field of the Action model.

:param action: action data (Rule.data.actions)
:param action_type: action type (Action.Type)
:return: action data without the excluded keys
"""

# # If we have a specific blob type, we need to sanitize the action data to the blob type
if action_type == Action.Type.SLACK:
return build_slack_data_blob(action).__dict__
# # Otherwise, we can just return the action data as is, removing the keys we don't want to save
else:
return {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

popping keys we save elsewhere or don't want to save

k: v
for k, v in action.items()
if k
not in [
ACTION_TYPE_TO_INTEGRATION_ID_KEY.get(action_type),
ACTION_TYPE_TO_TARGET_IDENTIFIER_KEY.get(action_type),
ACTION_TYPE_TO_TARGET_DISPLAY_KEY.get(action_type),
*EXCLUDED_ACTION_DATA_KEYS,
]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we remove these because these are used in metric alerts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we remove them b/c we already store them in some other field. i want to keep the blob as small as possible, containing extra context that we need to send a notification.

}


def build_notification_actions_from_rule_data_actions(
actions: list[dict[str, Any]]
) -> list[Action]:
"""
Builds notification actions from action field in Rule's data blob.

:param actions: list of action data (Rule.data.actions)
:return: list of notification actions (Action)
"""

notification_actions: list[Action] = []

for action in actions:
# Use Rule.integration.provider to get the action type
action_type = RULE_REGISTRY_ID_TO_INTEGRATION_PROVIDER.get(action.get("id"))
if action_type is None:
_logger.warning(
"Action type not found for action",
extra={
"action_id": action.get("id"),
"action_uuid": action.get("uuid"),
},
)
continue

# For all integrations, the target type is specific
# For email, the target type is user
# For sentry app, the target type is sentry app
# FWIW, we don't use target type for issue alerts
target_type = ACTION_TYPE_TO_TARGET_TYPE_RULE_REGISTRY.get(action_type)

if target_type == ActionTarget.SPECIFIC:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we're not using the target_type could we just say if the target type isn't set to specific, then continue or throw an exception? i think that might help make this code a little more readable since we could dedent a lot of it, but i'm not sure if we want to since the notification_action can be created w/o these properties.


# Get the integration_id
integration_id_key = ACTION_TYPE_TO_INTEGRATION_ID_KEY.get(action_type)
if integration_id_key is None:
# we should always have an integration id key if target type is specific
# TODO(iamrajjoshi): Should we fail loudly here?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends on how this method is being used to migrate a rule. I'd think of this as flow control, if we want to continue creating the new action / workflow then we should just log and continue like you have. If we want to stop creation of the workflow because it will be malformed, we should throw an error to stop the migration (or handle the issue in another area)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

talked with cathy, we will log the error and continue to build actions that we can. actions that we store in Rule that are malformed don't fire in the first place and we don't have any system that heals these actions, so we just won't migrate them.

_logger.warning(
"Integration ID key not found for action type",
extra={
"action_type": action_type,
"action_id": action.get("id"),
"action_uuid": action.get("uuid"),
},
)
continue
integration_id = action.get(integration_id_key)

# Get the target_identifier if it exists
target_identifier_key = ACTION_TYPE_TO_TARGET_IDENTIFIER_KEY.get(action_type)
if target_identifier_key is not None:
target_identifier = action.get(target_identifier_key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think target_identifier might need to be initialized outside of this if block, similarly with target_display below


# Get the target_display if it exists
target_display_key = ACTION_TYPE_TO_TARGET_DISPLAY_KEY.get(action_type)
if target_display_key is not None:
target_display = action.get(target_display_key)

notification_action = Action(
type=action_type,
data=(
# If the target type is specific, sanitize the action data
# Otherwise, use the action data as is
sanitize_action(action, action_type)
if target_type == ActionTarget.SPECIFIC
else action
),
integration_id=integration_id,
target_identifier=target_identifier,
target_display=target_display,
target_type=target_type,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would we want to create the action if any of these weren't set? (given the current code flow, that could be a possibility)

)

notification_actions.append(notification_action)

# Bulk create the actions, note: this won't call save(), not sure if we need to
Action.objects.bulk_create(notification_actions)

return notification_actions
10 changes: 10 additions & 0 deletions src/sentry/workflow_engine/models/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,18 @@ class Action(DefaultFieldsModel):
class Type(models.TextChoices):
EMAIL = "email"
SLACK = "slack"
DISCORD = "discord"
MSTEAMS = "msteams"
PAGERDUTY = "pagerduty"
OPSGENIE = "opsgenie"
GITHUB = "github"
GITHUB_ENTERPRISE = "github_enterprise"
JIRA = "jira"
JIRA_SERVER = "jira_server"
AZURE_DEVOPS = "azure_devops"
WEBHOOK = "webhook"
PLUGIN = "plugin"
SENTRY_APP = "sentry_app"

class LegacyNotificationType(models.TextChoices):
ISSUE_ALERT = "issue"
Expand Down
Empty file.
129 changes: 129 additions & 0 deletions src/sentry/workflow_engine/typings/notification_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from dataclasses import dataclass

from sentry.notifications.models.notificationaction import ActionTarget
from sentry.workflow_engine.models.action import Action

"""
Keys that are excluded from the action data blob.
We don't want to save these keys because:
- uuid: maps to action id
- id: maps to action type
"""
EXCLUDED_ACTION_DATA_KEYS = ["uuid", "id"]

"""
Action types that are integrations
"""
INTEGRATION_ACTION_TYPES = [
Action.Type.SLACK,
Action.Type.DISCORD,
Action.Type.MSTEAMS,
Action.Type.PAGERDUTY,
Action.Type.OPSGENIE,
Action.Type.GITHUB,
Action.Type.GITHUB_ENTERPRISE,
Action.Type.JIRA,
Action.Type.JIRA_SERVER,
Action.Type.AZURE_DEVOPS,
]

ACTION_TYPE_TO_TARGET_TYPE_RULE_REGISTRY: dict[Action.Type, ActionTarget] = {
Action.Type.SLACK: ActionTarget.SPECIFIC,
Action.Type.DISCORD: ActionTarget.SPECIFIC,
Action.Type.MSTEAMS: ActionTarget.SPECIFIC,
Action.Type.PAGERDUTY: ActionTarget.SPECIFIC,
Action.Type.OPSGENIE: ActionTarget.SPECIFIC,
Action.Type.GITHUB: ActionTarget.SPECIFIC,
Action.Type.GITHUB_ENTERPRISE: ActionTarget.SPECIFIC,
Action.Type.JIRA: ActionTarget.SPECIFIC,
Action.Type.JIRA_SERVER: ActionTarget.SPECIFIC,
Action.Type.AZURE_DEVOPS: ActionTarget.SPECIFIC,
Action.Type.SENTRY_APP: ActionTarget.SENTRY_APP,
Action.Type.EMAIL: ActionTarget.USER,
Action.Type.PLUGIN: ActionTarget.USER,
Action.Type.WEBHOOK: ActionTarget.USER,
}

INTEGRATION_PROVIDER_TO_RULE_REGISTRY_ID: dict[Action.Type, str] = {
Action.Type.SLACK: "sentry.integrations.slack.notify_action.SlackNotifyServiceAction",
Action.Type.DISCORD: "sentry.integrations.discord.notify_action.DiscordNotifyServiceAction",
Action.Type.MSTEAMS: "sentry.integrations.msteams.notify_action.MsTeamsNotifyServiceAction",
Action.Type.PAGERDUTY: "sentry.integrations.pagerduty.notify_action.PagerDutyNotifyServiceAction",
Action.Type.OPSGENIE: "sentry.integrations.opsgenie.notify_action.OpsgenieNotifyTeamAction",
Action.Type.GITHUB: "sentry.integrations.github.notify_action.GitHubCreateTicketAction",
Action.Type.GITHUB_ENTERPRISE: "sentry.integrations.github_enterprise.notify_action.GitHubEnterpriseCreateTicketAction",
Action.Type.JIRA: "sentry.integrations.jira.notify_action.JiraCreateTicketAction",
Action.Type.JIRA_SERVER: "sentry.integrations.jira_server.notify_action.JiraServerCreateTicketAction",
Action.Type.AZURE_DEVOPS: "sentry.integrations.vsts.notify_action.AzureDevopsCreateTicketAction",
Action.Type.SENTRY_APP: "sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction",
Action.Type.EMAIL: "sentry.mail.actions.NotifyEmailAction",
Action.Type.PLUGIN: "sentry.rules.actions.notify_event.NotifyEventAction",
Action.Type.WEBHOOK: "sentry.rules.actions.notify_event_service.NotifyEventServiceAction",
}

RULE_REGISTRY_ID_TO_INTEGRATION_PROVIDER: dict[str, Action.Type] = {
v: k for k, v in INTEGRATION_PROVIDER_TO_RULE_REGISTRY_ID.items()
}

ACTION_TYPE_TO_INTEGRATION_ID_KEY: dict[Action.Type, str] = {
Action.Type.SLACK: "workspace",
Action.Type.DISCORD: "server",
Action.Type.MSTEAMS: "team",
Action.Type.PAGERDUTY: "account",
Action.Type.OPSGENIE: "account",
Action.Type.GITHUB: "integration",
Action.Type.GITHUB_ENTERPRISE: "integration",
Action.Type.JIRA: "integration",
Action.Type.JIRA_SERVER: "integration",
Action.Type.AZURE_DEVOPS: "integration",
}

INTEGRATION_ID_KEY_TO_ACTION_TYPE: dict[str, Action.Type] = {
v: k for k, v in ACTION_TYPE_TO_INTEGRATION_ID_KEY.items()
}


ACTION_TYPE_TO_TARGET_IDENTIFIER_KEY: dict[Action.Type, str] = {
Action.Type.SLACK: "channel_id",
Action.Type.DISCORD: "channel_id",
Action.Type.MSTEAMS: "channel_id",
Action.Type.PAGERDUTY: "service",
Action.Type.OPSGENIE: "team",
}

TARGET_IDENTIFIER_KEY_TO_ACTION_TYPE: dict[str, Action.Type] = {
v: k for k, v in ACTION_TYPE_TO_TARGET_IDENTIFIER_KEY.items()
}

ACTION_TYPE_TO_TARGET_DISPLAY_KEY: dict[Action.Type, str] = {
Action.Type.SLACK: "channel",
Action.Type.MSTEAMS: "channel",
}

TARGET_DISPLAY_KEY_TO_ACTION_TYPE: dict[str, Action.Type] = {
v: k for k, v in ACTION_TYPE_TO_TARGET_DISPLAY_KEY.items()
}


@dataclass
class DataBlob:
"""
DataBlob is a generic type that represents the data blob for a notification action.
"""

pass


@dataclass
class SlackDataBlob(DataBlob):
"""
SlackDataBlob is a specific type that represents the data blob for a Slack notification action.
"""

tags: str = ""
notes: str = ""


ACTION_TYPE_2_BLOB_TYPE: dict[Action.Type, type[DataBlob]] = {
Action.Type.SLACK: SlackDataBlob,
}
Empty file.
Loading
Loading