Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3a46222
chore(reports): deprecate Slack v1, default ALERT_REPORT_SLACK_V2 to …
sadpandajoe May 6, 2026
92b2750
test(reports): mock get_channels_with_search in slack token-callable …
sadpandajoe May 6, 2026
398e4a4
fix(reports): make Slack v2 backoff retries actually fire on transien…
sadpandajoe May 6, 2026
d394025
docs(updating): rewrite slack v1 deprecation note in conventional one…
sadpandajoe May 6, 2026
af800ba
refactor(reports): address review feedback on Slack v1 deprecation
sadpandajoe May 7, 2026
05a5217
refactor(reports): narrow Slack v1 scope-missing warning to actual sc…
sadpandajoe Jun 12, 2026
a4aa1f4
fix(reports): read Slack scope-missing error code from dict or SlackR…
sadpandajoe Jun 15, 2026
b4ce1ff
test(reports): cover SlackResponse.data scope path and backoff recovery
sadpandajoe Jun 16, 2026
3f60bf2
fix(reports): harden Slack v2 fallback and retries
sadpandajoe Jun 25, 2026
e1c63fa
Merge remote-tracking branch 'origin/master' into claude/angry-bartik…
sadpandajoe Jun 26, 2026
1378c28
fix(reports): harden Slack v2 probe, atomic recipient upgrade, retry …
sadpandajoe Jun 26, 2026
e773de2
Merge remote-tracking branch 'origin/master' into claude/angry-bartik…
sadpandajoe Jun 29, 2026
026e5c4
docs(reports): clarify Slack files.upload retirement timeline
sadpandajoe Jul 1, 2026
e975e18
docs(reports): name both Slack scopes in v1 deprecation comment
sadpandajoe Jul 1, 2026
b2d1fa7
Merge remote-tracking branch 'origin/master' into claude/angry-bartik…
sadpandajoe Jul 2, 2026
17c8d68
Merge remote-tracking branch 'origin/master' into claude/angry-bartik…
sadpandajoe Jul 3, 2026
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
2 changes: 2 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config

The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.

- [39914](https://github.com/apache/superset/pull/39914) `ALERT_REPORT_SLACK_V2` now defaults to `True` and the legacy Slack v1 integration (`Slack` recipient type, `files.upload` API) is deprecated for removal in the next major. Slack blocked new apps from `files.upload` in May 2024 and fully retired the method for all apps on November 12, 2025; because the v1 path sends files through `files.upload`, v1 file-bearing sends now fail at the API level — only text-only `chat_postMessage` still works via the legacy path. Grant your Slack bot the `channels:read` and `groups:read` scopes so existing `Slack` recipients can be auto-upgraded to `SlackV2` on next send. Operators who explicitly override the flag to `False`, or whose Slack bot is missing those scopes, will see deprecation warnings while text-only sends continue through the legacy path.

### Soft delete and restore for dashboards

**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
Expand Down
4 changes: 2 additions & 2 deletions docs/static/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@
},
{
"name": "ALERT_REPORT_SLACK_V2",
"default": false,
"default": true,
"lifecycle": "testing",
"description": "Enables Slack V2 integration for Alerts and Reports"
"description": "Enables Slack V2 integration for Alerts and Reports. Defaults to True; the legacy Slack v1 path is deprecated and will be removed in the next major release. Operators must grant the Slack bot both the `channels:read` and `groups:read` scopes so existing v1 recipients can be auto-upgraded on their next send. Without those scopes, file uploads fail (Slack retired the `files.upload` endpoint in 2025) and only text-only `chat_postMessage` sends will continue to work via the legacy path."
},
{
"name": "ALERT_REPORT_WEBHOOK",
Expand Down
88 changes: 41 additions & 47 deletions superset/commands/report/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,62 +163,56 @@ def update_report_schedule_slack_v2(self) -> None:
"""
Update the report schedule type and channels for all slack recipients to v2.
V2 uses ids instead of names for channels.

Channel ids for every Slack recipient are resolved first and the
recipients are only mutated once all of them resolve. This keeps the
upgrade all-or-nothing: a single unresolvable channel can no longer
leave the schedule with some recipients already switched to v2 (and
persisted by a later error-log commit) while others are untouched.
"""
# Track each recipient mutated in this pass with its original (type,
# config) so a partial failure can revert ALL of them — not just the
# loop variable. Restoring the in-memory values to their loaded state
# keeps a later commit from persisting a half-migrated set.
mutated: list[tuple[ReportRecipients, ReportRecipientType, str]] = []
resolved: list[tuple[ReportRecipients, str]] = []
try:
for recipient in self._report_schedule.recipients:
if recipient.type == ReportRecipientType.SLACK:
mutated.append(
(recipient, recipient.type, recipient.recipient_config_json)
)
recipient.type = ReportRecipientType.SLACKV2
slack_recipients = json.loads(recipient.recipient_config_json)
# V1 method allowed to use leading `#` in the channel name
channel_names = (slack_recipients["target"] or "").replace("#", "")
# we need to ensure that existing reports can also fetch
# ids from private channels
channels = get_channels_with_search(
search_string=channel_names,
types=[
SlackChannelTypes.PRIVATE,
SlackChannelTypes.PUBLIC,
],
exact_match=True,
)
channels_list = recipients_string_to_list(channel_names)
if len(channels_list) != len(channels):
missing_channels = set(channels_list) - {
channel["name"] for channel in channels
}
msg = (
"Could not find the following channels: "
f"{', '.join(missing_channels)}"
)
raise UpdateFailedError(msg)
channel_ids = ",".join(channel["id"] for channel in channels)
recipient.recipient_config_json = json.dumps(
{
"target": channel_ids,
}
if recipient.type != ReportRecipientType.SLACK:
continue
slack_recipients = json.loads(recipient.recipient_config_json)
# V1 method allowed to use leading `#` in the channel name
channel_names = (slack_recipients["target"] or "").replace("#", "")
# we need to ensure that existing reports can also fetch
# ids from private channels
channels = get_channels_with_search(
search_string=channel_names,
types=[
SlackChannelTypes.PRIVATE,
SlackChannelTypes.PUBLIC,
],
exact_match=True,
)
channels_list = recipients_string_to_list(channel_names)
if len(channels_list) != len(channels):
missing_channels = set(channels_list) - {
channel["name"] for channel in channels
}
msg = (
"Could not find the following channels: "
f"{', '.join(missing_channels)}"
)
raise UpdateFailedError(msg)
channel_ids = ",".join(channel["id"] for channel in channels)
resolved.append((recipient, json.dumps({"target": channel_ids})))
except Exception as ex:
# Revert every mutated recipient to v1 (both type AND config) to
# preserve configuration (requires manual fix). Reverting the full
# set — not just the loop variable — keeps earlier recipients
# consistent; iterating the snapshot also avoids the UnboundLocalError
# that a bare loop-variable reference raises on a pre-iteration
# failure (which would mask the real error).
for reverted_recipient, original_type, original_config in mutated:
reverted_recipient.type = original_type
reverted_recipient.recipient_config_json = original_config
# No recipient has been mutated yet, so there is no partial upgrade
# to revert; surface the failure so the configuration can be fixed
# manually.
msg = f"Failed to update slack recipients to v2: {str(ex)}"
logger.exception(msg)
raise UpdateFailedError(msg) from ex

# Every Slack recipient resolved; apply the upgrade atomically.
for recipient, recipient_config_json in resolved:
recipient.type = ReportRecipientType.SLACKV2
recipient.recipient_config_json = recipient_config_json

def create_log(self, error_message: Optional[str] = None) -> None:
"""
Creates a Report execution log, uses the current computed last_value for Alerts
Expand Down
10 changes: 8 additions & 2 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,9 +705,15 @@ class D3TimeFormat(TypedDict, total=False):
# @lifecycle: testing
# @docs: https://superset.apache.org/docs/configuration/alerts-reports
"ALERT_REPORTS": False,
# Enables Slack V2 integration for Alerts and Reports
# Enables Slack V2 integration for Alerts and Reports.
# Defaults to True; the legacy Slack v1 path is deprecated and will be removed
# in the next major release. Operators must grant the Slack bot both the
# `channels:read` and `groups:read` scopes so existing v1 recipients can be
# auto-upgraded on their next send. Without those scopes, file uploads fail
# (Slack retired the `files.upload` endpoint in 2025) and only text-only
# `chat_postMessage` sends will continue to work via the legacy path.
# @lifecycle: testing
"ALERT_REPORT_SLACK_V2": False,
"ALERT_REPORT_SLACK_V2": True,
# Enables webhook integration for Alerts and Reports
# @lifecycle: testing
"ALERT_REPORT_WEBHOOK": False,
Expand Down
7 changes: 6 additions & 1 deletion superset/reports/notifications/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@
logger = logging.getLogger(__name__)


# TODO: Deprecated: Remove this class in Superset 6.0.0
# Deprecated: Slack v1 will be removed in the next major release. The Slack
# `files.upload` endpoint was retired in 2025, so file-bearing sends already
# fail at the API level; only text-only `chat_postMessage` sends still work
# here. When the Slack bot has the `channels:read` and `groups:read` scopes,
# existing v1 recipients are auto-upgraded to SlackV2 on first send via
# `update_report_schedule_slack_v2`.
class SlackNotification(SlackMixin, BaseNotification): # pylint: disable=too-few-public-methods
"""
Sends a slack notification for a report recipient
Expand Down
57 changes: 53 additions & 4 deletions superset/reports/notifications/slackv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import logging
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from io import IOBase
from typing import List, Union

Expand Down Expand Up @@ -48,6 +48,55 @@

logger = logging.getLogger(__name__)

_TRANSIENT_SLACK_API_ERROR_CODES = frozenset(
{
"fatal_error",
"internal_error",
"ratelimited",
"request_timeout",
"rollup_error",
"service_unavailable",
"timeout",
}
)
Comment thread
sadpandajoe marked this conversation as resolved.


def _get_slack_api_error_code(ex: SlackApiError) -> str:
response = getattr(ex, "response", None)
data = getattr(response, "data", None)
if not isinstance(data, dict):
data = response if isinstance(response, dict) else {}
return str(data.get("error") or "")


def _get_slack_api_status_code(ex: SlackApiError) -> int | None:
response = getattr(ex, "response", None)
return getattr(response, "status_code", None)


def _give_up_slack_api_retry(ex: Exception) -> bool:
if not isinstance(ex, SlackApiError):
return False

status_code = _get_slack_api_status_code(ex)
if status_code == 429 or (status_code is not None and 500 <= status_code < 600):
return False

error_code = _get_slack_api_error_code(ex)
return bool(error_code and error_code not in _TRANSIENT_SLACK_API_ERROR_CODES)


@backoff.on_exception(
backoff.expo,
(SlackApiError, SlackClientNotConnectedError),
factor=10,
base=2,
max_tries=5,
giveup=_give_up_slack_api_retry,
)
def _call_slack_api(method: Callable[..., object], **kwargs: object) -> None:
method(**kwargs)


class SlackV2Notification(SlackMixin, BaseNotification): # pylint: disable=too-few-public-methods
"""
Expand Down Expand Up @@ -77,7 +126,6 @@ def _get_inline_files(
return ("pdf", [self._content.pdf])
return (None, [])

@backoff.on_exception(backoff.expo, SlackApiError, factor=10, base=2, max_tries=5)
@statsd_gauge("reports.slack.send")
def send(self) -> None:
global_logs_context = getattr(g, "logs_context", {}) or {}
Expand All @@ -98,15 +146,16 @@ def send(self) -> None:
for channel in channels:
if len(files) > 0:
for file in files:
client.files_upload_v2(
_call_slack_api(
client.files_upload_v2,
channel=channel,
file=file,
initial_comment=body,
title=title,
filename=file_name,
)
else:
client.chat_postMessage(channel=channel, text=body)
_call_slack_api(client.chat_postMessage, channel=channel, text=body)
Comment thread
sadpandajoe marked this conversation as resolved.

logger.info(
"Report sent to slack",
Expand Down
102 changes: 94 additions & 8 deletions superset/utils/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
# under the License.


import functools
import logging
import warnings
from typing import Any, Callable, Optional

from flask import current_app as app
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from slack_sdk.errors import SlackApiError, SlackClientError as SlackSDKClientError
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler

from superset import feature_flag_manager
Expand All @@ -34,12 +36,42 @@

logger = logging.getLogger(__name__)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Add an explicit type annotation to this module-level constant to satisfy the type-hint requirement for relevant variables. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This new module-level constant is unannotated even though its type is clear and stable, so it matches the rule requiring type hints on newly added Python variables that can be annotated.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/utils/slack.py
**Line:** 38:38
**Comment:**
	*Custom Rule: Add an explicit type annotation to this module-level constant to satisfy the type-hint requirement for relevant variables.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

_SLACK_V1_DEPRECATION_MESSAGE = (
"Slack v1 (the legacy `Slack` recipient type and `files.upload` API) is "
"deprecated and will be removed in the next major release. Slack retired "
"the `files.upload` endpoint in 2025, so v1 file uploads no longer work; "
"only text-only `chat_postMessage` sends still succeed. Grant your Slack "
"bot the `channels:read` and `groups:read` scopes so existing v1 "
"recipients can be auto-upgraded to SlackV2 on "
"their next send."
)
Comment thread
sadpandajoe marked this conversation as resolved.


# functools.cache gives us a process-lifetime, thread-safe one-shot guard
# without the read-then-write race that bare module globals would have under
# multi-threaded WSGI workers. The cached return value (None) is irrelevant —
# we only care that the body executes at most once per process.
@functools.cache
def _emit_v1_flag_off_deprecation() -> None:
warnings.warn(_SLACK_V1_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=3)
logger.warning(
"ALERT_REPORT_SLACK_V2 is disabled; %s", _SLACK_V1_DEPRECATION_MESSAGE
)
Comment thread
sadpandajoe marked this conversation as resolved.


@functools.cache
def _emit_v1_scope_missing_deprecation() -> None:
warnings.warn(_SLACK_V1_DEPRECATION_MESSAGE, DeprecationWarning, stacklevel=3)

Comment thread
sadpandajoe marked this conversation as resolved.

class SlackChannelTypes(StrEnum):
PUBLIC = "public_channel"
PRIVATE = "private_channel"


_SLACK_CONVERSATION_TYPES = ",".join(SlackChannelTypes)
Comment thread
sadpandajoe marked this conversation as resolved.


class SlackClientError(Exception):
pass

Expand Down Expand Up @@ -117,7 +149,7 @@ def _get_channels(
client = get_slack_client()
channel_schema = SlackChannelSchema()
channels: list[SlackChannelSchema] = []
extra_params = {"types": ",".join(SlackChannelTypes)}
extra_params = {"types": _SLACK_CONVERSATION_TYPES}
Comment thread
sadpandajoe marked this conversation as resolved.
if team_id:
extra_params["team_id"] = team_id
cursor = None
Expand Down Expand Up @@ -224,21 +256,75 @@ def get_channels_with_search(
return channels


_SCOPE_MISSING_ERROR_CODES = frozenset(
{"missing_scope", "not_allowed_token_type", "no_permission"}
)
Comment thread
sadpandajoe marked this conversation as resolved.


def should_use_v2_api() -> bool:
if not feature_flag_manager.is_feature_enabled("ALERT_REPORT_SLACK_V2"):
_emit_v1_flag_off_deprecation()
return False
try:
client = get_slack_client()
extra_params = {"types": _SLACK_CONVERSATION_TYPES}
Comment thread
sadpandajoe marked this conversation as resolved.
team_id = get_team_id()
client.conversations_list(**({"team_id": team_id} if team_id else {}))
if team_id:
extra_params["team_id"] = team_id
client.conversations_list(
limit=1,
exclude_archived=True,
**extra_params,
)
logger.info("Slack API v2 is available")
return True
except SlackApiError:
# use the v1 api but warn with a deprecation message
except SlackApiError as ex:
# Only the scope-missing branch is a v1-deprecation signal; other
# SlackApiError codes (invalid_auth, ratelimited, server errors, etc.)
# are unrelated probe failures and should not be reported as a missing
# scope. We still fall back to v1 in both cases so a transient probe
# failure doesn't break sends — operators get an actionable log either
# way.
# `response` is normally a SlackResponse whose payload lives in `.data`,
# but the SDK (and our tests) can also hand back a plain dict. Read the
# error code in either shape so the scope-missing branch isn't missed.
response = getattr(ex, "response", None)
data = getattr(response, "data", None)
if not isinstance(data, dict):
data = response if isinstance(response, dict) else {}
error_code = data.get("error", "")
if error_code in _SCOPE_MISSING_ERROR_CODES:
# The DeprecationWarning fires once per process, but the actionable
# log line fires every send so operators see it in their report logs.
_emit_v1_scope_missing_deprecation()
logger.warning(
"Slack bot is missing the `channels:read` and `groups:read` "
"scopes; falling back to the deprecated "
"v1 API. %s",
_SLACK_V1_DEPRECATION_MESSAGE,
)
else:
logger.warning(
"Slack v2 probe failed with error %r; falling back to the "
"deprecated v1 API for this send. Investigate the underlying "
Comment thread
sadpandajoe marked this conversation as resolved.
"Slack API error — this is not a missing-scope problem.",
error_code or str(ex),
)
return False
except SlackSDKClientError as ex:
# Non-API SDK failures (e.g. SlackClientNotConnectedError,
# SlackRequestError, SlackClientConfigurationError) are not subclasses
# of SlackApiError, so without this branch they would escape the probe
# raw. The caller runs this probe *before* the mapped Slack send `try`,
# so an un-caught probe error aborts the entire recipient loop instead
# of failing a single recipient. Treat any probe connection/transport
# failure as "v2 unavailable" and fall back to the deprecated v1 API,
# matching the SlackApiError behavior above.
logger.warning(
"Your current Slack scopes are missing `channels:read`. Please add "
"this to your Slack app in order to continue using the v1 API. Support "
"for the old Slack API will be removed in Superset version 6.0.0."
"Slack v2 probe failed to connect (%s: %s); falling back to the "
"deprecated v1 API for this send.",
type(ex).__name__,
ex,
)
return False

Expand Down
Loading
Loading