From 25db391c371bb9b1484e34dd446ffb33e55e4921 Mon Sep 17 00:00:00 2001 From: Nickyux Date: Wed, 2 Jul 2025 16:17:30 +0200 Subject: [PATCH 01/12] feat: separate parsers into legacy & new --- lib/legacyParser.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++ lib/parser.ts | 4 +- 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 lib/legacyParser.ts diff --git a/lib/legacyParser.ts b/lib/legacyParser.ts new file mode 100644 index 0000000..6a80b67 --- /dev/null +++ b/lib/legacyParser.ts @@ -0,0 +1,132 @@ +type SentryIssue = Record; + +export function getEvent(issue: SentryIssue) { + return issue?.event ?? issue?.data?.issue ?? issue; +} + +export function getProject(issue: SentryIssue) { + return issue?.project?.project_name ?? getEvent(issue)?.project?.name ?? issue.project_name; +} + +export function getPlatform(issue: SentryIssue) { + return getEvent(issue)?.platform; +} + +export function getLanguage(issue: SentryIssue) { + return getEvent(issue)?.location?.split(".")?.slice(-1)?.[0] || ""; +} + +export function getContexts(issue: SentryIssue) { + const contexts = getEvent(issue)?.contexts ?? {}; + const values = Object.values(contexts) + .map((value: Record) => `${value?.name} ${value?.version}`) + // TODO: Have a better decision tree here + .filter((value) => value !== "undefined undefined"); + + return values ?? []; +} + +export function getExtras(issue: SentryIssue) { + const extras = getEvent(issue)?.extra ?? {}; + const values = Object.entries(extras).map( + ([key, value]) => `${key}: ${value}` + ); + + return values ?? []; +} + +export function getLink(issue: SentryIssue) { + return issue?.url ?? "https://sentry.io"; +} + +export function getTags(issue: SentryIssue) { + return getEvent(issue)?.tags ?? []; +} + +export function getLevel(issue: SentryIssue) { + return getEvent(issue)?.level; +} + +export function getType(issue: SentryIssue) { + return getEvent(issue)?.type; +} + +export function getTitle(issue: SentryIssue) { + return getEvent(issue)?.title ?? "Sentry Event"; +} + +export function getTime(issue: SentryIssue) { + const event = getEvent(issue); + + if (event?.timestamp) { + return new Date(getEvent(issue)?.timestamp * 1000); + } + + if (event?.lastSeen != null) { + return new Date(event?.lastSeen); + } + + if (event?.firstSeen != null) { + return new Date(event?.firstSeen); + } + + return new Date(); +} + +export function getRelease(issue: SentryIssue) { + return getEvent(issue)?.release; +} + +export function getUser(issue: SentryIssue) { + return getEvent(issue)?.user; +} + +export function getFileLocation(issue: SentryIssue) { + return getEvent(issue)?.location; +} + +export function getStacktrace(issue: SentryIssue) { + return ( + getEvent(issue)?.stacktrace ?? + getEvent(issue)?.exception?.values[0]?.stacktrace + ); +} + +export function getErrorLocation(issue: SentryIssue, maxLines = Infinity) { + const stacktrace = getStacktrace(issue); + const locations = stacktrace?.frames; /*.reverse();*/ + + let files = locations?.map( + (location) => + `${location?.filename}, ${location?.lineno ?? "?"}:${ + location?.colno ?? "?" + }` + ); + + if (maxLines < Infinity && files?.length > maxLines) { + files = files.slice(0, maxLines); + files.push("..."); + } + + return files; +} + +export function getErrorCodeSnippet(issue: SentryIssue) { + const stacktrace = getStacktrace(issue); + const location = stacktrace?.frames?.reverse()?.[0]; + + if (!location) { + const event = getEvent(issue); + return event?.culprit ?? null; + } + + // The spaces below are intentional - they help align the code + // aorund the additional `>` marker + return ` ${location.pre_context?.join("\n ") ?? ""}\n>${ + location.context_line + }\n${location.post_context?.join("\n") ?? ""}`; +} + +export function getMessage(issue: SentryIssue) { + return issue?.message; +} diff --git a/lib/parser.ts b/lib/parser.ts index 7e1dba6..e1e8e0e 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -1,7 +1,7 @@ type SentryIssue = Record; export function getEvent(issue: SentryIssue) { - return issue?.event ?? issue?.data?.issue ?? issue; + return issue?.event ?? issue?.data?.issue ?? issue?.data?.event ?? issue; } export function getProject(issue: SentryIssue) { @@ -128,5 +128,5 @@ export function getErrorCodeSnippet(issue: SentryIssue) { } export function getMessage(issue: SentryIssue) { - return issue?.message; + return issue?.message ?? getEvent(issue)?.message; } From 063388408912e40a7b318a4a2c59704dd3c86efc Mon Sep 17 00:00:00 2001 From: Nickyux Date: Wed, 2 Jul 2025 16:18:07 +0200 Subject: [PATCH 02/12] feat: separate legacy from new API requests and handle independently --- lib/message.ts | 157 +++++++++++++++++++++++++++++++++++- pages/api/webhooks/[key].ts | 25 ++++-- 2 files changed, 174 insertions(+), 8 deletions(-) diff --git a/lib/message.ts b/lib/message.ts index 0ec401d..cfc4781 100644 --- a/lib/message.ts +++ b/lib/message.ts @@ -1,5 +1,6 @@ import { APIEmbedField, EmbedBuilder } from "discord.js"; import getColor from "./colors"; +import * as legacyParser from "./legacyParser"; import * as parser from "./parser"; function cap(str: string, length: number) { @@ -10,11 +11,35 @@ function cap(str: string, length: number) { return str.substr(0, length - 1) + "\u2026"; } -export default function createMessage(event) { +export function createMessage(event) { + console.debug("Received new event"); + + console.debug({ + event: parser.getEvent(event), + project: parser.getProject(event), + platform: parser.getPlatform(event), + language: parser.getLanguage(event), + contexts: parser.getContexts(event), + extras: parser.getExtras(event), + link: parser.getLink(event), + tags: parser.getTags(event), + level: parser.getLevel(event), + type: parser.getType(event), + title: parser.getTitle(event), + time: parser.getTime(event), + user: parser.getUser(event), + release: parser.getRelease(event), + fileLocation: parser.getFileLocation(event), + stackTrace: parser.getStacktrace(event), + errorLocation: parser.getErrorLocation(event, 7), + errorCodeSnippet: parser.getErrorCodeSnippet(event), + message: parser.getMessage(event), + }); + const embed = new EmbedBuilder() .setColor(getColor(parser.getLevel(event))) .setAuthor({ - name: event.project_name, + name: event.data.triggered_rule, iconURL: "https://sentrydiscord.dev/icons/sentry.png", }) .setFooter({ @@ -114,3 +139,131 @@ export default function createMessage(event) { embeds: [embed.toJSON()], }; } + +export function createLegacyMessage(event) { + console.debug("Received legacy event"); + + console.debug({ + event: legacyParser.getEvent(event), + project: legacyParser.getProject(event), + platform: legacyParser.getPlatform(event), + language: legacyParser.getLanguage(event), + contexts: legacyParser.getContexts(event), + extras: legacyParser.getExtras(event), + link: legacyParser.getLink(event), + tags: legacyParser.getTags(event), + level: legacyParser.getLevel(event), + type: legacyParser.getType(event), + title: legacyParser.getTitle(event), + time: legacyParser.getTime(event), + user: legacyParser.getUser(event), + release: legacyParser.getRelease(event), + fileLocation: legacyParser.getFileLocation(event), + stackTrace: legacyParser.getStacktrace(event), + errorLocation: legacyParser.getErrorLocation(event, 7), + errorCodeSnippet: legacyParser.getErrorCodeSnippet(event), + message: legacyParser.getMessage(event), + }); + const embed = new EmbedBuilder() + .setColor(getColor(legacyParser.getLevel(event))) + .setAuthor({ + name: event.project_name, + iconURL: "https://sentrydiscord.dev/icons/sentry.png", + }) + .setFooter({ + text: "Please consider sponsoring us!", + iconURL: "https://sentrydiscord.dev/sponsor.png", + }) + .setTimestamp(legacyParser.getTime(event)); + + const projectName = legacyParser.getProject(event); + + const eventTitle = legacyParser.getTitle(event); + + if (projectName) { + const embedTitle = `[${projectName}] ${eventTitle}`; + embed.setTitle(cap(embedTitle, 250)); + } else { + embed.setTitle(cap(eventTitle, 250)); + } + + const link = legacyParser.getLink(event); + if (link.startsWith("https://") || link.startsWith("http://")) { + embed.setURL(legacyParser.getLink(event)); + } + + const fileLocation = legacyParser.getFileLocation(event); + const snippet = cap(legacyParser.getErrorCodeSnippet(event), 3900); + + if (snippet) { + embed.setDescription( + `${fileLocation ? `\`📄 ${fileLocation.slice(-95)}\`\n` : ""}\`\`\`${ + legacyParser.getLanguage(event) ?? legacyParser.getPlatform(event) + }\n${snippet} + \`\`\`` + ); + } else { + embed.setDescription("Unable to generate code snippet."); + } + + const fields: APIEmbedField[] = []; + + const location = legacyParser.getErrorLocation(event, 7); + if (location?.length > 0) { + fields.push({ + name: "Stack", + value: `\`\`\`${cap(location.join("\n"), 1000)}\n\`\`\``, + }); + } + + const user = legacyParser.getUser(event); + if (user?.username) { + fields.push({ + name: "User", + value: cap(`${user.username} ${user.id ? `(${user.id})` : ""}`, 1024), + inline: true, + }); + } + + const tags = legacyParser.getTags(event); + if (Object.keys(tags).length > 0) { + fields.push({ + name: "Tags", + value: cap( + tags.map(([key, value]) => `${key}: ${value}`).join("\n"), + 1024 + ), + inline: true, + }); + } + + const extras = legacyParser.getExtras(event); + if (extras.length > 0) { + fields.push({ + name: "Extras", + value: cap(extras.join("\n"), 1024), + inline: true, + }); + } + + const contexts = legacyParser.getContexts(event); + if (contexts.length > 0) { + fields.push({ + name: "Contexts", + value: cap(contexts.join("\n"), 1024), + inline: true, + }); + } + + const release = legacyParser.getRelease(event); + if (release) { + fields.push({ name: "Release", value: cap(release, 1024), inline: true }); + } + + embed.addFields(fields); + return { + username: "Sentry", + avatar_url: `https://sentrydiscord.dev/icons/sentry.png`, + embeds: [embed.toJSON()], + }; +} diff --git a/pages/api/webhooks/[key].ts b/pages/api/webhooks/[key].ts index 6edd425..3bd277a 100644 --- a/pages/api/webhooks/[key].ts +++ b/pages/api/webhooks/[key].ts @@ -1,7 +1,7 @@ import prisma from '../../../lib/database'; import nextConnect from 'next-connect'; import { getPlatform } from '../../../lib/parser'; -import createMessage from '../../../lib/message'; +import { createMessage, createLegacyMessage } from '../../../lib/message'; import type { NextApiRequest, NextApiResponse } from 'next'; const handler = async (request: NextApiRequest, response: NextApiResponse) => { @@ -10,15 +10,26 @@ const handler = async (request: NextApiRequest, response: NextApiResponse) => { try { let { key, thread_id: threadId } = request.query; + // Legacy Integrations do not have a 'sentry-hook-resource' key. + // Therefore, we can differentiate between legacy and new integrations with this. + const sentryHookResource = request.headers['sentry-hook-resource']; + + if (sentryHookResource && sentryHookResource !== 'event_alert') { + // This probably needs to be discussed. + console.log(`Received ${sentryHookResource} event for ${key}, ignoring.`); + if (process.env.NODE_ENV === 'development' || request.query.debug) { + console.log({ body: request.body, headers: request.headers }); + } + return response.status(204); + } + if (Array.isArray(key)) { key = key[0]; } + console.log(`Received event for ${key}`); if (process.env.NODE_ENV === 'development' || request.query.debug) { - console.log(`Received event for ${key}`); - console.log({ body: request.body }); - } else { - console.log(`Received event for ${key}`); + console.log({ body: request.body, headers: request.headers }); } if (request.body == null) { @@ -38,7 +49,9 @@ const handler = async (request: NextApiRequest, response: NextApiResponse) => { return response.status(404); } - message = createMessage(request.body); + message = ( + sentryHookResource ? createMessage(request.body) : createLegacyMessage(request.body) + ) console.log('Constructed embed'); console.log({ key }); From 6c10baced361dc2d28e5f614dae0c331790d3cea Mon Sep 17 00:00:00 2001 From: Nickyux Date: Wed, 2 Jul 2025 17:15:28 +0200 Subject: [PATCH 03/12] feat: type SentryEvent and update the parser for new Integrations --- lib/message.ts | 32 ++++---- lib/parser.ts | 212 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 161 insertions(+), 83 deletions(-) diff --git a/lib/message.ts b/lib/message.ts index cfc4781..9947962 100644 --- a/lib/message.ts +++ b/lib/message.ts @@ -11,12 +11,13 @@ function cap(str: string, length: number) { return str.substr(0, length - 1) + "\u2026"; } -export function createMessage(event) { +export function createMessage(requestBody) { console.debug("Received new event"); + const event = parser.getEvent(requestBody); + console.debug({ event: parser.getEvent(event), - project: parser.getProject(event), platform: parser.getPlatform(event), language: parser.getLanguage(event), contexts: parser.getContexts(event), @@ -36,10 +37,12 @@ export function createMessage(event) { message: parser.getMessage(event), }); + const eventLevel = parser.getLevel(event); + const embed = new EmbedBuilder() - .setColor(getColor(parser.getLevel(event))) + .setColor(getColor(eventLevel)) .setAuthor({ - name: event.data.triggered_rule, + name: requestBody.triggered_rule ?? "Sentry Event", iconURL: "https://sentrydiscord.dev/icons/sentry.png", }) .setFooter({ @@ -48,16 +51,7 @@ export function createMessage(event) { }) .setTimestamp(parser.getTime(event)); - const projectName = parser.getProject(event); - - const eventTitle = parser.getTitle(event); - - if (projectName) { - const embedTitle = `[${projectName}] ${eventTitle}`; - embed.setTitle(cap(embedTitle, 250)); - } else { - embed.setTitle(cap(eventTitle, 250)); - } + embed.setTitle(cap(parser.getTitle(event), 250)); const link = parser.getLink(event); if (link.startsWith("https://") || link.startsWith("http://")) { @@ -67,17 +61,19 @@ export function createMessage(event) { const fileLocation = parser.getFileLocation(event); const snippet = cap(parser.getErrorCodeSnippet(event), 3900); + let descriptionText = `> ${event.culprit ? `**${eventLevel.toUpperCase()}**: \`${event.culprit.slice(-95)}\` ${event.environment ? `on ${event.environment}` : ""}\n` : ""}\n`; + if (snippet) { - embed.setDescription( - `${fileLocation ? `\`📄 ${fileLocation.slice(-95)}\`\n` : ""}\`\`\`${ + descriptionText += `${fileLocation ? `\`📄 ${fileLocation.slice(-95)}\`\n` : ""}\`\`\`${ parser.getLanguage(event) ?? parser.getPlatform(event) }\n${snippet} \`\`\`` - ); } else { - embed.setDescription("Unable to generate code snippet."); + descriptionText += "Unable to generate code snippet."; } + embed.setDescription(descriptionText); + const fields: APIEmbedField[] = []; const location = parser.getErrorLocation(event, 7); diff --git a/lib/parser.ts b/lib/parser.ts index e1e8e0e..e75052c 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -1,33 +1,120 @@ type SentryIssue = Record; -export function getEvent(issue: SentryIssue) { - return issue?.event ?? issue?.data?.issue ?? issue?.data?.event ?? issue; -} - -export function getProject(issue: SentryIssue) { - return issue?.project?.project_name ?? getEvent(issue)?.project?.name; -} - -export function getPlatform(issue: SentryIssue) { - return getEvent(issue)?.platform; -} - -export function getLanguage(issue: SentryIssue) { - return getEvent(issue)?.location?.split(".")?.slice(-1)?.[0] || ""; -} - -export function getContexts(issue: SentryIssue) { - const contexts = getEvent(issue)?.contexts ?? {}; - const values = Object.values(contexts) - .map((value: Record) => `${value?.name} ${value?.version}`) - // TODO: Have a better decision tree here - .filter((value) => value !== "undefined undefined"); +type SentryEvent = { + event_id: string; + project: string; + release?: string; + dist?: string, + platform?: string; + message?: string, + datetime?: string; + tags?: Record; + _metrics?: Record; + _ref?: number; + _ref_version?: number; + contexts?: Record; + culprit?: string; + environment?: string; + extra?: Record; + fingerprint?: Array; + grouping_config?: Record; + hashes?: Array; + level?: string; + location?: string; + logentry?: { + formatted?: string; + message?: string; + params?: Array; + }; + logger?: string; + metadata?: Record; + modules?: Record; + nodestore_insert?: number; + received?: number; + request?: { + url?: string; + method?: string; + headers?: Record; + data?: string; + env?: Record; + inferred_content_type?: string; + api_target?: string; + cookies?: Record; + }; + stacktrace?: { + frames?: Array<{ + function?: string, + module?: string, + filename?: string, + abs_path?: string, + lineno?: number, + pre_context?: Array, + context_line?: string, + post_context?: Array, + in_app?: boolean, + vars?: Record, + colno?: number, + data?: Record, + errors?: Array, + raw_function?: string, + image_addr?: string, + instruction_addr?: string, + addr_mode?: string, + package?: string, + platform?: string, + source_link?: string, + symbol?: string, + symbol_addr?: string, + trust?: boolean, + lock?: boolean, + }>; + }; + timestamp?: number; + title?: string; + type?: string; + user?: { + id?: string; + email?: string; + ip_address?: string; + username?: string; + name?: string; + geo?: { + country_code?: string; + region?: string; + city?: string; + }; + }; + version?: string; + url?: string; + web_url?: string; + issue_url?: string; + issue_id?: string; +}; + + +export function getEvent(issue: SentryIssue): SentryEvent { + return issue?.data?.event ?? issue; +} + +export function getPlatform(event: SentryEvent) { + return event?.platform || "unknown"; +} + +export function getLanguage(event: SentryEvent) { + return event?.location?.split(".")?.slice(-1)?.[0] || ""; +} + +export function getContexts(event: SentryEvent) { + const contexts = getEvent(event)?.contexts ?? {}; + const values = Object.values(contexts) + .map((value: Record) => `${value?.name} ${value?.version}`) + .filter((value) => value !== "undefined undefined"); return values ?? []; } -export function getExtras(issue: SentryIssue) { - const extras = getEvent(issue)?.extra ?? {}; +export function getExtras(event: SentryEvent) { + const extras = event?.extra ?? {}; const values = Object.entries(extras).map( ([key, value]) => `${key}: ${value}` ); @@ -35,65 +122,56 @@ export function getExtras(issue: SentryIssue) { return values ?? []; } -export function getLink(issue: SentryIssue) { - return issue?.url ?? "https://sentry.io"; +export function getLink(event: SentryEvent) { + return event?.url ?? "https://sentry.io"; } -export function getTags(issue: SentryIssue) { - return getEvent(issue)?.tags ?? []; +export function getTags(event: SentryEvent) { + return event?.tags ?? []; } -export function getLevel(issue: SentryIssue) { - return getEvent(issue)?.level; +export function getLevel(event: SentryEvent) { + return event?.level; } -export function getType(issue: SentryIssue) { - return getEvent(issue)?.type; +export function getType(event: SentryEvent) { + return event?.type; } -export function getTitle(issue: SentryIssue) { - return getEvent(issue)?.title ?? "Sentry Event"; +export function getTitle(event: SentryEvent) { + return event?.title ?? "Sentry Event"; } -export function getTime(issue: SentryIssue) { - const event = getEvent(issue); - +export function getTime(event: SentryEvent) { if (event?.timestamp) { - return new Date(getEvent(issue)?.timestamp * 1000); - } - - if (event?.lastSeen != null) { - return new Date(event?.lastSeen); - } - - if (event?.firstSeen != null) { - return new Date(event?.firstSeen); + return new Date(event?.timestamp * 1000); } return new Date(); } -export function getRelease(issue: SentryIssue) { - return getEvent(issue)?.release; +export function getRelease(event: SentryEvent) { + return event?.release; } -export function getUser(issue: SentryIssue) { - return getEvent(issue)?.user; +export function getUser(event: SentryEvent) { + return event?.user; } -export function getFileLocation(issue: SentryIssue) { - return getEvent(issue)?.location; +export function getFileLocation(event: SentryEvent) { + return event?.location; } -export function getStacktrace(issue: SentryIssue) { +export function getStacktrace(event: SentryEvent) { return ( - getEvent(issue)?.stacktrace ?? - getEvent(issue)?.exception?.values[0]?.stacktrace + event?.stacktrace || { + frames: [], + } ); } -export function getErrorLocation(issue: SentryIssue, maxLines = Infinity) { - const stacktrace = getStacktrace(issue); +export function getErrorLocation(event: SentryEvent, maxLines = Infinity) { + const stacktrace = getStacktrace(event); const locations = stacktrace?.frames; /*.reverse();*/ let files = locations?.map( @@ -111,22 +189,26 @@ export function getErrorLocation(issue: SentryIssue, maxLines = Infinity) { return files; } -export function getErrorCodeSnippet(issue: SentryIssue) { - const stacktrace = getStacktrace(issue); +export function getErrorCodeSnippet(event: SentryEvent) { + const stacktrace = getStacktrace(event); const location = stacktrace?.frames?.reverse()?.[0]; if (!location) { - const event = getEvent(issue); return event?.culprit ?? null; } - // The spaces below are intentional - they help align the code - // aorund the additional `>` marker + const startingLine = location.lineno - (location.pre_context?.length ?? 0); + return ` ${location.pre_context?.join("\n ") ?? ""}\n>${ location.context_line - }\n${location.post_context?.join("\n") ?? ""}`; + }\n ${location.post_context?.join("\n ") ?? ""}`; + + // TODO: Consider adding line numbers to the code snippet + return ` ${location.pre_context?.map((line, index) => `${startingLine - location.pre_context?.length ?? 0 + index} | ${line}`).join("\n ") ?? ""}\n>${location.lineno}>| ${ + location.context_line + }\n${location.post_context?.map((line, index) => `${index + location.lineno + 1} | ${line}`).join("\n ") ?? ""}`; } -export function getMessage(issue: SentryIssue) { - return issue?.message ?? getEvent(issue)?.message; +export function getMessage(event: SentryEvent) { + return event?.message; } From f078270322af3606f241544fdf7afd6c93b3772e Mon Sep 17 00:00:00 2001 From: Nickyux Date: Thu, 3 Jul 2025 12:09:24 +0200 Subject: [PATCH 04/12] update native integration faq --- pages/index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/pages/index.tsx b/pages/index.tsx index bd07688..5f9a78d 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -266,13 +266,11 @@ export default function Home({ events, webhooks }) {

- - Me too! There's an{" "} - - open issue on GitHub - {" "} - that you can go and leave reactions on to help get it - prioritized. If official support lands, this service will + + Unfortunately, the native Sentry integration for Discord is + only available for paid Sentry plans. This service provides + a free alternative for everyone! If the native integration + ever becomes available for free plans, this service will likely stop allowing new registrations but will remain up so long as webhooks are receiving events. From df5ee4a84cb4df3344eb4ddaa37e750f41a2be12 Mon Sep 17 00:00:00 2001 From: Nickyux Date: Thu, 3 Jul 2025 12:09:49 +0200 Subject: [PATCH 05/12] update integration-creation flow & include demo --- pages/create.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/pages/create.tsx b/pages/create.tsx index 1fb9cc4..2c58ebb 100644 --- a/pages/create.tsx +++ b/pages/create.tsx @@ -212,15 +212,24 @@ export default function Create() {

Finally, add the Webhook Integration to Sentry

-

- You can find it under Settings →{' '} - IntegrationsWebhooks. - Add it to your project, and then in the{' '} - Configure screen add the above link to the{' '} - Callback URLs. That's it! Save your changes, - and click "Test plugin" to see it in action. +

    +
  • + Create a new integration by going to SettingsCustom IntegrationsCreate New Integration, and selecting Internal Integration. +
  • +
  • + Paste the above link into the Webhook URL field and enable Alert Rule Action. +
  • +
  • + Go to AlertsCreate Alert and set up a new rule to Send a notification via an integration, and choose the integration you just created. +
  • +
+

+ Confused? Check out the demo below for a walkthrough!

-

+

+