Skip to content

Conversation

@JHZLO
Copy link

@JHZLO JHZLO commented Dec 4, 2025

Problem

When using a custom template for Slack alert notifications, both the default template fields (Query/Alert links) AND the custom template are displayed together.

Current behavior:

Query: /queries/123
Alert: /alerts/9
Description: [custom template content]

Expected behavior:
When custom_body is set, only the custom template should be displayed.

Root Cause

The Slack destination uses append() to add custom_body to the existing fields instead of replacing them:

fields = [
    {"title": "Query", ...},
    {"title": "Alert", ...},
]
if alert.custom_body:
    fields.append({"title": "Description", "value": alert.custom_body})  # appends instead of replaces

Solution

Restructure the notification logic:

  • If custom_body is set → send only the custom template
  • If custom_body is not set → send default template (existing behavior)

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

I think you need to run the DB migration scripts to add the selector field after upgrading.
Have you run migration script like below ?

docker compose run --rm server manage db upgrade

Thank you.

@JHZLO
Copy link
Author

JHZLO commented Dec 6, 2025

@yoshiokatsuneo
Thanks for the response!

This is not a migration issue.
When creating an alert via the UI, Redash stores the column name as options.column, but render_template() tries to access self.options["selector"], which causes KeyError.

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you.
So, in you case, is the selector specified at alert setting UI below stored as options field on the DB ?

image

When a custom template is configured for an alert, the Slack destination
was appending the custom_body to the default fields (Query/Alert links)
instead of replacing them. This resulted in both default and custom
templates being displayed together.

This fix restructures the notification logic:
- If custom_body is set: send only the custom template
- If custom_body is not set: send default template (existing behavior)
@JHZLO JHZLO force-pushed the fix/slack-custom-template-notification branch from bb6f00f to f370589 Compare December 7, 2025 05:28
@JHZLO
Copy link
Author

JHZLO commented Dec 7, 2025

@yoshiokatsuneo
I apologize for my initial response. You were right.

I'm not sure exactly what triggered it, but until lastweek the selector key was not being added (I suspect it was the Helm post-upgrade hook that automatically runs DB migrations). When I tested again today and checked the database, I found that the selector key now exists. I confirmed this was resolved by the migration in #7475. Thank you!

However, there's still a valid bug: when using a custom template, both the default template fields (Query/Alert links) AND the custom template are displayed together in Slack notifications.
I'm not sure if this is intentional, but users who set up a custom template would likely expect only their custom template to be sent. The current implementation uses append() to add custom_body to the existing fields, resulting in both being displayed. I've updated this PR to focus only on the Slack destination fix - sending only the custom template when custom_body is set.

@JHZLO JHZLO changed the title fix(alerts): Fix Slack custom template notification issues fix(slack): Send only custom template when custom_body is set Dec 7, 2025
@yoshiokatsuneo
Copy link
Contributor

yoshiokatsuneo commented Dec 8, 2025

@JHZLO

Thank you for your comment.
I'm not sure what is the best solution.

As, this PR make it possible to skip Query/Alert part.
But, this PR make it impossible to show Query/Alert field as it is now.

image

@JHZLO
Copy link
Author

JHZLO commented Dec 13, 2025

@yoshiokatsuneo Thank you
I understand your concern - this change would remove the ability to show Query/Alert fields alongside custom content.

However, I believe custom-template-only is the expected behavior because:
스크린샷 2025-12-12 오후 5 16 52

  1. Redash already provides template variables - Users can include {{QUERY_URL}}, {{ALERT_URL}}, {{QUERY_NAME}}, etc. in their custom templates. So users who want Query/Alert links can easily add them to their custom template.
  2. "Custom" implies replacement - When users choose "Custom template", they likely expect full control over the notification content, not an addition to the default.
  3. Current behavior causes duplication - If a user includes {{QUERY_URL}} in their custom template (which is common), the same information appears twice.

That said, I acknowledge this is just my perspective, and there may be users who prefer the current append behavior.

Proposal: What do you think about adding an option like "Include default fields" (checkbox) when custom template is enabled? This would:

  • Default to false (custom template only) - cleaner for most use cases
  • Allow users to opt-in to true if they want both default fields + custom content

This way, we support both use cases without breaking existing functionality for users who might depend on it.
Would this approach work for you?

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you.
Related to the compatibility, is custom_body handled as markdown or plain text in this PR ?

@JHZLO
Copy link
Author

JHZLO commented Dec 14, 2025

@yoshiokatsuneo

Thank you for your question. I've updated the PR to support markdown in custom_body.

I added mrkdwn_in: ["text", "fields"] to enable Slack markdown formatting:

- payload = {"attachments": [{"text": text, "color": color, "mrkdwn_in": ["text"], "fields": [{"value": alert.custom_body}]}]}
+ payload = {"attachments": [{"text": text, "color": color, "mrkdwn_in": ["text", "fields"], "fields": [{"value": alert.custom_body}]}]}

Slack API mrkdwn_in Specification
According to https://api.slack.com/reference/surfaces/formatting:
Attachments by default have markdown turned off. The only way to enable them is to pass the mrkdwn_in field.

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you.

I'm wondering what happens if template variable (ex: QUERY_RESULT_ROWS) text is handled as markdown string, and it is intended behavior or not.

How do you think ?

@JHZLO
Copy link
Author

JHZLO commented Dec 16, 2025

@yoshiokatsuneo

That's a really good point I hadn't considered.

If a template variable value contains markdown special characters (e.g., QUERY_RESULT_VALUE = j_hzlo),
it would be unintentionally formatted as j hzlo (italic) - which is not the intended behavior.

I considered several solutions to escape template variable values, but:

  • It would significantly increase the PR scope
  • It doesn't align with the original purpose of this PR (custom template only display)

Although the alert feature isn't a major part of Redash, I believe we should take a defensive
approach for edge cases like this to ensure a better user experience.

I removed the markdown support and kept it as plain text (same as the original behavior).

Thank you for pointing this out!

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

I removed the markdown support and kept it as plain text (same as the original behavior).

Thank you.
Well, then, it make it impossible to show title string(like "Query", "Alert", "Description") as heading style as before the PR... Umm...

@JHZLO
Copy link
Author

JHZLO commented Dec 17, 2025

@yoshiokatsuneo
Oh sorry, I missed that! I added the title field back

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you.

And, I understand that the it is nice if we can fully customize the alert message.
At the same time, I feel this feature makes alert UI a little bit difficult to understand.

Is this change important for you ?

@JHZLO
Copy link
Author

JHZLO commented Dec 18, 2025

@yoshiokatsuneo
If Query/Alert information couldn't be customized, I would understand keeping the default fields.
However, since users can already include {{QUERY_URL}}, {{ALERT_URL}}, {{QUERY_NAME}}, etc.
in their custom templates, the default fields become redundant.

To me, the word "Custom" implies full control over the output. When I set a custom template,
I expect only my template to be displayed - not a mix of default fields + custom content.

Currently, I want to use custom templates, but the inconsistent output structure
(default fields appearing alongside my custom content) makes it difficult to use in practice.

This is why I believe "custom template only" is the expected behavior.

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you.
Probably, I feel it is nice if we can make the feature like below, to make the feature simple, and to make it compatible with current behavior.

  • There is no needless extra options. (like default header.)
  • custom_body can fully customize the message.
  • custom_body can be written as markdown for rich text.
  • Variables included in the custom_body can be written as text(not markdown). (optionally we can add triple bracket({{{}}}) for markdown as handlebars)
  • Migration script can convert existing custom_body to new custom_body with headers, for compatibility.

How do you think ?

@JHZLO
Copy link
Author

JHZLO commented Dec 18, 2025

@yoshiokatsuneo

I totally agree with your proposal. However, I'd like to share some considerations

  1. Slack markdown requires mrkdwn_in setting - To enable markdown in custom_body,
    we need to pass mrkdwn_in: ["text", "fields"] to the Slack API.

  2. Escaping {{variable}} as plain text requires significant changes - Currently,
    Alert.render_template() uses pystache with HTML escaping only. To escape
    Slack-specific markdown characters (*, _, ~, <>), we need to modify the
    rendering logic.

  3. Different destinations have different markdown specs:

    Destination Format
    Slack *bold*, _italic_
    Discord **bold**, *italic*
    MS Teams MessageCard markdown
    Email/HangoutsChat HTML
    ChatWork Custom syntax
  4. Abstraction layer needed - Since each destination has different escaping requirements,
    we should first abstract the escape strategy before implementing markdown support.

Given this scope, I think we should design the abstraction layer first before implementing
the full markdown support. What do you think about this approach?

I'm happy to work on this larger scope PR if you agree.

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Escaping {{variable}} as plain text requires significant changes - Currently,
Alert.render_template() uses pystache with HTML escaping only. To escape
Slack-specific markdown characters (*, _, ~, <>), we need to modify the
rendering logic.

I agree that this is a bit challenging.
Do you think it is possible to escape variable in the Slack markdown consistently ?

@JHZLO
Copy link
Author

JHZLO commented Dec 19, 2025

@yoshiokatsuneo

I tested escape approaches for Slack markdown special characters.

Slack doesn't provide an official way to escape markdown formatting characters

Character Official escape method
&, <, > HTML entity (&amp;, &lt;, &gt;)
*, _, ~ No official method

Since there's no official escape mechanism for *, _, ~, I researched and tested unofficial workarounds based on Unicode Standard Annex.

Unofficial Workarounds Tested

I found two commonly suggested approaches:

  1. Soft hyphen (U+00AD) - Insert invisible character before special characters
  2. Combining Grapheme Joiner (U+034F) - Insert zero-width joiner before special characters

Both methods work by inserting an invisible Unicode character that breaks Slack's markdown pattern matching.

Test Commands (curl)

I tested both approaches by sending messages to a Slack webhook:

# Normal (formatting applied)
curl -X POST -H 'Content-type: application/json' \
  --data '{"attachments":[{"text":"*bold* _italic_ ~strike~","mrkdwn_in":["text"]}]}' \
  WEBHOOK_URL

# CGJ escaped (no formatting)
curl -X POST -H 'Content-type: application/json' \
  --data $'{"attachments":[{"text":"\u034f*bold* \u034f_italic_ \u034f~strike~","mrkdwn_in":["text"]}]}' \
  WEBHOOK_URL

Test Results

image
Method Escape Works Copy/Paste Behavior
Soft hyphen (U+00AD) O Unicode visible in text editors
CGJ (U+034F) O Original text preserved

Both methods successfully prevent markdown formatting. However, the Soft hyphen approach has a drawback - when users copy the text from Slack and paste it into a text editor, the invisible Unicode character becomes visible.
image

CGJ (U+034F) is the better approach - it escapes markdown formatting while preserving the original text appearance when copied to other applications.

Implementation Approach

Based on these findings, I believe we can implement this using pystache's custom escape function feature. Here's my proposed approach:

1. Add methods to Alert model:

These methods allow destinations to pass their own escape function:

def get_custom_body(self, escape_func=None):
    template = self.options.get("custom_body", self.options.get("template"))
    return self.render_template(template, escape_func=escape_func)

def get_custom_subject(self, escape_func=None):
    template = self.options.get("custom_subject")
    return self.render_template(template, escape_func=escape_func)

2. Add escape function in Slack destination:

This function handles both official HTML entity escaping and unofficial CGJ-based markdown escaping:

def escape_slack_markdown(text):
    if text is None:
        return ""
    text = str(text)
    # HTML entities (official Slack requirement)
    text = text.replace('&', '&amp;')
    text = text.replace('<', '&lt;')
    text = text.replace('>', '&gt;')
    # Slack markdown characters (CGJ workaround)
    for char in ['*', '_', '~', '`']:
        text = text.replace(char, '\u034f' + char)
    return text

This way:

  • {{variable}} → escaped (safe from unintended formatting)
  • {{{variable}}} → raw output (user can intentionally use markdown)

This gives users full control over their custom templates while protecting against unexpected formatting from template variables.

What do you think about this approach?

@yoshiokatsuneo
Copy link
Contributor

@JHZLO

Thank you for the detailed summary !

The CGJ (U+034F) approach keep visibility for copy/paste operation. But, I'm afraid the actual data(bytes) is different.

For example, I'm afraid that searching * with CGJ that is copied/pasted from slack message may not match * without CGJ in the search target text.

Or, if the copied text with CGJ is embedded in the source code unexpectedly, it may be the cause of the bug...

How do you think ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants