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/message.ts b/lib/message.ts index 0ec401d..e512bd2 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,15 @@ function cap(str: string, length: number) { return str.substr(0, length - 1) + "\u2026"; } -export default function createMessage(event) { +export function createMessage(requestBody) { + const event = parser.getEvent(requestBody); + + const eventLevel = parser.getLevel(event); + const embed = new EmbedBuilder() - .setColor(getColor(parser.getLevel(event))) + .setColor(getColor(eventLevel)) .setAuthor({ - name: event.project_name, + name: requestBody?.data?.triggered_rule ?? "Sentry Event", iconURL: "https://sentrydiscord.dev/icons/sentry.png", }) .setFooter({ @@ -23,9 +28,107 @@ export default function createMessage(event) { }) .setTimestamp(parser.getTime(event)); - const projectName = parser.getProject(event); + embed.setTitle(cap(parser.getTitle(event), 250)); + + const link = parser.getLink(event); + if (link.startsWith("https://") || link.startsWith("http://")) { + embed.setURL(parser.getLink(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) { + descriptionText += `${fileLocation ? `\`📄 ${fileLocation.slice(-95)}\`\n` : ""}\`\`\`${ + parser.getLanguage(event) ?? parser.getPlatform(event) + }\n${snippet} + \`\`\`` + } else { + descriptionText += "Unable to generate code snippet."; + } + + embed.setDescription(descriptionText); + + const fields: APIEmbedField[] = []; + + const location = parser.getErrorLocation(event, 7); + if (location?.length > 0) { + fields.push({ + name: "Stack", + value: `\`\`\`${cap(location.join("\n"), 1000)}\n\`\`\``, + }); + } + + const user = parser.getUser(event); + if (user?.username) { + fields.push({ + name: "User", + value: cap(`${user.username} ${user.id ? `(${user.id})` : ""}`, 1024), + inline: true, + }); + } + + const tags = parser.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 = parser.getExtras(event); + if (extras.length > 0) { + fields.push({ + name: "Extras", + value: cap(extras.join("\n"), 1024), + inline: true, + }); + } + + const contexts = parser.getContexts(event); + if (contexts.length > 0) { + fields.push({ + name: "Contexts", + value: cap(contexts.join("\n"), 1024), + inline: true, + }); + } + + const release = parser.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()], + }; +} + +export function createLegacyMessage(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 = parser.getTitle(event); + const eventTitle = legacyParser.getTitle(event); if (projectName) { const embedTitle = `[${projectName}] ${eventTitle}`; @@ -34,18 +137,18 @@ export default function createMessage(event) { embed.setTitle(cap(eventTitle, 250)); } - const link = parser.getLink(event); + const link = legacyParser.getLink(event); if (link.startsWith("https://") || link.startsWith("http://")) { - embed.setURL(parser.getLink(event)); + embed.setURL(legacyParser.getLink(event)); } - const fileLocation = parser.getFileLocation(event); - const snippet = cap(parser.getErrorCodeSnippet(event), 3900); + const fileLocation = legacyParser.getFileLocation(event); + const snippet = cap(legacyParser.getErrorCodeSnippet(event), 3900); if (snippet) { embed.setDescription( `${fileLocation ? `\`📄 ${fileLocation.slice(-95)}\`\n` : ""}\`\`\`${ - parser.getLanguage(event) ?? parser.getPlatform(event) + legacyParser.getLanguage(event) ?? legacyParser.getPlatform(event) }\n${snippet} \`\`\`` ); @@ -55,7 +158,7 @@ export default function createMessage(event) { const fields: APIEmbedField[] = []; - const location = parser.getErrorLocation(event, 7); + const location = legacyParser.getErrorLocation(event, 7); if (location?.length > 0) { fields.push({ name: "Stack", @@ -63,7 +166,7 @@ export default function createMessage(event) { }); } - const user = parser.getUser(event); + const user = legacyParser.getUser(event); if (user?.username) { fields.push({ name: "User", @@ -72,7 +175,7 @@ export default function createMessage(event) { }); } - const tags = parser.getTags(event); + const tags = legacyParser.getTags(event); if (Object.keys(tags).length > 0) { fields.push({ name: "Tags", @@ -84,7 +187,7 @@ export default function createMessage(event) { }); } - const extras = parser.getExtras(event); + const extras = legacyParser.getExtras(event); if (extras.length > 0) { fields.push({ name: "Extras", @@ -93,7 +196,7 @@ export default function createMessage(event) { }); } - const contexts = parser.getContexts(event); + const contexts = legacyParser.getContexts(event); if (contexts.length > 0) { fields.push({ name: "Contexts", @@ -102,7 +205,7 @@ export default function createMessage(event) { }); } - const release = parser.getRelease(event); + const release = legacyParser.getRelease(event); if (release) { fields.push({ name: "Release", value: cap(release, 1024), inline: true }); } diff --git a/lib/parser.ts b/lib/parser.ts index 7e1dba6..ffcf07e 100644 --- a/lib/parser.ts +++ b/lib/parser.ts @@ -1,33 +1,123 @@ 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; -} - -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 SentryStacktrace = { + 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; + }>; +}; + +type SentryEvent = { + event_id: string; + project: string; + release?: string; + dist?: string, + platform?: string; + message?: string, + datetime?: string; + tags?: Array<[string, string]>; + _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?: SentryStacktrace; + 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; + exception?: { + values: Array<{ + type?: string; + value?: string; + module?: string; + mechanism?: { + type?: string; + handled?: boolean; + data?: Record; + }; + stacktrace?: SentryStacktrace; + }>; + } +}; + + +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 +125,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?.web_url ?? 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 || event?.exception?.values?.[0]?.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 +192,19 @@ 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 + return ` ${location.pre_context?.join("\n ") ?? ""}\n>${ location.context_line - }\n${location.post_context?.join("\n") ?? ""}`; + }\n ${location.post_context?.join("\n ") ?? ""}`; } -export function getMessage(issue: SentryIssue) { - return issue?.message; +export function getMessage(event: SentryEvent) { + return event?.message; } 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 }); 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!

-

+

+