424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
import {
|
|
type ChatPostMessageArguments,
|
|
ErrorCode,
|
|
type WebAPIPlatformError,
|
|
type WebAPIRateLimitedError,
|
|
} from "@slack/web-api";
|
|
import { type ProjectAlertChannelType } from "@trigger.dev/database";
|
|
import assertNever from "assert-never";
|
|
import { prisma } from "~/db.server";
|
|
import { env } from "~/env.server";
|
|
import { v3ErrorPath } from "~/utils/pathBuilder";
|
|
import {
|
|
isIntegrationForService,
|
|
type OrganizationIntegrationForService,
|
|
OrgIntegrationRepository,
|
|
} from "~/models/orgIntegration.server";
|
|
import {
|
|
ProjectAlertEmailProperties,
|
|
ProjectAlertSlackProperties,
|
|
ProjectAlertWebhookProperties,
|
|
} from "~/models/projectAlert.server";
|
|
import { sendAlertEmail } from "~/services/email.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
|
import { subtle } from "crypto";
|
|
import { generateErrorGroupWebhookPayload } from "./errorGroupWebhook.server";
|
|
import { safeWebhookFetch } from "./safeWebhookFetch.server";
|
|
|
|
type ErrorAlertClassification = "new_issue" | "regression" | "unignored";
|
|
|
|
interface ErrorAlertPayload {
|
|
channelId: string;
|
|
projectId: string;
|
|
classification: ErrorAlertClassification;
|
|
error: {
|
|
fingerprint: string;
|
|
environmentId: string;
|
|
environmentSlug: string;
|
|
environmentName: string;
|
|
taskIdentifier: string;
|
|
errorType: string;
|
|
errorMessage: string;
|
|
sampleStackTrace: string;
|
|
firstSeen: string;
|
|
lastSeen: string;
|
|
occurrenceCount: number;
|
|
};
|
|
}
|
|
|
|
class SkipRetryError extends Error {}
|
|
|
|
export class DeliverErrorGroupAlertService {
|
|
async call(payload: ErrorAlertPayload): Promise<void> {
|
|
const channel = await prisma.projectAlertChannel.findFirst({
|
|
where: { id: payload.channelId, enabled: true },
|
|
include: {
|
|
project: {
|
|
include: {
|
|
organization: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!channel) {
|
|
logger.warn("[DeliverErrorGroupAlert] Channel not found or disabled", {
|
|
channelId: payload.channelId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const errorLink = this.#buildErrorLink(
|
|
channel.project.organization,
|
|
channel.project,
|
|
payload.error
|
|
);
|
|
|
|
try {
|
|
switch (channel.type) {
|
|
case "EMAIL":
|
|
await this.#sendEmail(channel, payload, errorLink);
|
|
break;
|
|
case "SLACK":
|
|
await this.#sendSlack(channel, payload, errorLink);
|
|
break;
|
|
case "WEBHOOK":
|
|
await this.#sendWebhook(channel, payload, errorLink);
|
|
break;
|
|
default:
|
|
assertNever(channel.type);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SkipRetryError) {
|
|
logger.warn("[DeliverErrorGroupAlert] Skipping retry", {
|
|
reason: (error as Error).message,
|
|
});
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#buildErrorLink(
|
|
organization: { slug: string },
|
|
project: { slug: string },
|
|
error: ErrorAlertPayload["error"]
|
|
): string {
|
|
return `${env.APP_ORIGIN}${v3ErrorPath(organization, project, { slug: error.environmentSlug }, { fingerprint: error.fingerprint })}`;
|
|
}
|
|
|
|
#classificationLabel(classification: ErrorAlertClassification): string {
|
|
switch (classification) {
|
|
case "new_issue":
|
|
return "New error";
|
|
case "regression":
|
|
return "Regression";
|
|
case "unignored":
|
|
return "Error resurfaced";
|
|
}
|
|
}
|
|
|
|
async #sendEmail(
|
|
channel: {
|
|
type: ProjectAlertChannelType;
|
|
properties: unknown;
|
|
project: { name: string; organization: { title: string } };
|
|
},
|
|
payload: ErrorAlertPayload,
|
|
errorLink: string
|
|
): Promise<void> {
|
|
const emailProperties = ProjectAlertEmailProperties.safeParse(channel.properties);
|
|
if (!emailProperties.success) {
|
|
logger.error("[DeliverErrorGroupAlert] Failed to parse email properties", {
|
|
issues: emailProperties.error.issues,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await sendAlertEmail({
|
|
email: "alert-error-group",
|
|
to: emailProperties.data.email,
|
|
classification: payload.classification,
|
|
taskIdentifier: payload.error.taskIdentifier,
|
|
environment: payload.error.environmentName,
|
|
error: {
|
|
message: payload.error.errorMessage,
|
|
type: payload.error.errorType,
|
|
stackTrace: payload.error.sampleStackTrace || undefined,
|
|
},
|
|
occurrenceCount: payload.error.occurrenceCount,
|
|
errorLink,
|
|
organization: channel.project.organization.title,
|
|
project: channel.project.name,
|
|
});
|
|
}
|
|
|
|
async #sendSlack(
|
|
channel: {
|
|
type: ProjectAlertChannelType;
|
|
properties: unknown;
|
|
project: { organizationId: string; name: string; organization: { title: string } };
|
|
},
|
|
payload: ErrorAlertPayload,
|
|
errorLink: string
|
|
): Promise<void> {
|
|
const slackProperties = ProjectAlertSlackProperties.safeParse(channel.properties);
|
|
if (!slackProperties.success) {
|
|
logger.error("[DeliverErrorGroupAlert] Failed to parse slack properties", {
|
|
issues: slackProperties.error.issues,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const integration = slackProperties.data.integrationId
|
|
? await prisma.organizationIntegration.findFirst({
|
|
where: {
|
|
id: slackProperties.data.integrationId,
|
|
organizationId: channel.project.organizationId,
|
|
},
|
|
include: { tokenReference: true },
|
|
})
|
|
: await prisma.organizationIntegration.findFirst({
|
|
where: {
|
|
service: "SLACK",
|
|
organizationId: channel.project.organizationId,
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
include: { tokenReference: true },
|
|
});
|
|
|
|
if (!integration || !isIntegrationForService(integration, "SLACK")) {
|
|
logger.error("[DeliverErrorGroupAlert] Slack integration not found");
|
|
return;
|
|
}
|
|
|
|
const message = this.#buildErrorGroupSlackMessage(payload, errorLink, channel.project.name);
|
|
|
|
await this.#postSlackMessage(integration, {
|
|
channel: slackProperties.data.channelId,
|
|
...message,
|
|
} as ChatPostMessageArguments);
|
|
}
|
|
|
|
async #sendWebhook(
|
|
channel: {
|
|
type: ProjectAlertChannelType;
|
|
properties: unknown;
|
|
project: {
|
|
id: string;
|
|
externalRef: string;
|
|
slug: string;
|
|
name: string;
|
|
organizationId: string;
|
|
organization: { slug: string; title: string };
|
|
};
|
|
},
|
|
payload: ErrorAlertPayload,
|
|
errorLink: string
|
|
): Promise<void> {
|
|
const webhookProperties = ProjectAlertWebhookProperties.safeParse(channel.properties);
|
|
if (!webhookProperties.success) {
|
|
logger.error("[DeliverErrorGroupAlert] Failed to parse webhook properties", {
|
|
issues: webhookProperties.error.issues,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const webhookPayload = generateErrorGroupWebhookPayload({
|
|
classification: payload.classification,
|
|
error: payload.error,
|
|
organization: {
|
|
id: channel.project.organizationId,
|
|
slug: channel.project.organization.slug,
|
|
name: channel.project.organization.title,
|
|
},
|
|
project: {
|
|
id: channel.project.id,
|
|
externalRef: channel.project.externalRef,
|
|
slug: channel.project.slug,
|
|
name: channel.project.name,
|
|
},
|
|
dashboardUrl: errorLink,
|
|
});
|
|
|
|
const rawPayload = JSON.stringify(webhookPayload);
|
|
const hashPayload = Buffer.from(rawPayload, "utf-8");
|
|
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhookProperties.data.secret);
|
|
const hmacSecret = Buffer.from(secret, "utf-8");
|
|
const key = await subtle.importKey(
|
|
"raw",
|
|
hmacSecret,
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"]
|
|
);
|
|
const signature = await subtle.sign("HMAC", key, hashPayload);
|
|
const signatureHex = Buffer.from(signature).toString("hex");
|
|
|
|
// Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts).
|
|
const response = await safeWebhookFetch(webhookProperties.data.url, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trigger-signature-hmacsha256": signatureHex,
|
|
},
|
|
body: rawPayload,
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
logger.info("[DeliverErrorGroupAlert] Failed to send webhook", {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: webhookProperties.data.url,
|
|
});
|
|
throw new Error(`Failed to send error group alert webhook to ${webhookProperties.data.url}`);
|
|
}
|
|
}
|
|
|
|
async #postSlackMessage(
|
|
integration: OrganizationIntegrationForService<"SLACK">,
|
|
message: ChatPostMessageArguments
|
|
) {
|
|
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
|
|
integration,
|
|
{ forceBotToken: true }
|
|
);
|
|
|
|
try {
|
|
return await client.chat.postMessage({
|
|
...message,
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
});
|
|
} catch (error) {
|
|
if (isWebAPIRateLimitedError(error)) {
|
|
throw new Error("Slack rate limited");
|
|
}
|
|
if (isWebAPIPlatformError(error)) {
|
|
if (
|
|
(error as WebAPIPlatformError).data.error === "invalid_blocks" ||
|
|
(error as WebAPIPlatformError).data.error === "account_inactive"
|
|
) {
|
|
throw new SkipRetryError(`Slack: ${(error as WebAPIPlatformError).data.error}`);
|
|
}
|
|
throw new Error("Slack platform error");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#buildErrorGroupSlackMessage(
|
|
payload: ErrorAlertPayload,
|
|
errorLink: string,
|
|
projectName: string
|
|
): { text: string; blocks: object[]; attachments: object[] } {
|
|
const label = this.#classificationLabel(payload.classification);
|
|
const errorType = payload.error.errorType || "Error";
|
|
const task = payload.error.taskIdentifier;
|
|
const envName = payload.error.environmentName;
|
|
|
|
return {
|
|
text: `${label}: ${errorType} in ${task} [${envName}]`,
|
|
blocks: [
|
|
{
|
|
type: "section",
|
|
text: {
|
|
type: "mrkdwn",
|
|
text: `*${label} in ${task} [${envName}]*`,
|
|
},
|
|
},
|
|
],
|
|
attachments: [
|
|
{
|
|
color: "danger",
|
|
blocks: [
|
|
{
|
|
type: "section",
|
|
text: {
|
|
type: "mrkdwn",
|
|
text: this.#wrapInCodeBlock(
|
|
payload.error.sampleStackTrace || payload.error.errorMessage
|
|
),
|
|
},
|
|
},
|
|
{
|
|
type: "section",
|
|
fields: [
|
|
{
|
|
type: "mrkdwn",
|
|
text: `*Task:*\n${task}`,
|
|
},
|
|
{
|
|
type: "mrkdwn",
|
|
text: `*Environment:*\n${envName}`,
|
|
},
|
|
{
|
|
type: "mrkdwn",
|
|
text: `*Project:*\n${projectName}`,
|
|
},
|
|
{
|
|
type: "mrkdwn",
|
|
text: `*Occurrences:*\n${payload.error.occurrenceCount}`,
|
|
},
|
|
{
|
|
type: "mrkdwn",
|
|
text: `*Last seen:*\n${this.#formatTimestamp(new Date(Number(payload.error.lastSeen)))}`,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "actions",
|
|
elements: [
|
|
{
|
|
type: "button",
|
|
text: { type: "plain_text", text: "Investigate" },
|
|
url: errorLink,
|
|
style: "primary",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
#wrapInCodeBlock(text: string, maxLength = 3000) {
|
|
const wrapperLength = 6; // ``` prefix + ``` suffix
|
|
const truncationSuffix = "\n\n...truncated — check dashboard for full error";
|
|
const innerMax = maxLength - wrapperLength;
|
|
|
|
const truncated =
|
|
text.length > innerMax
|
|
? text.slice(0, innerMax - truncationSuffix.length) + truncationSuffix
|
|
: text;
|
|
return `\`\`\`${truncated}\`\`\``;
|
|
}
|
|
|
|
#formatTimestamp(date: Date): string {
|
|
const unix = Math.floor(date.getTime() / 1000);
|
|
const fallback =
|
|
new Intl.DateTimeFormat("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
hour12: true,
|
|
timeZone: "UTC",
|
|
}).format(date) + " UTC";
|
|
return `<!date^${unix}^{date_short_pretty} {time_secs}|${fallback}>`;
|
|
}
|
|
}
|
|
|
|
function isWebAPIPlatformError(error: unknown): error is WebAPIPlatformError {
|
|
return (error as WebAPIPlatformError).code === ErrorCode.PlatformError;
|
|
}
|
|
|
|
function isWebAPIRateLimitedError(error: unknown): error is WebAPIRateLimitedError {
|
|
return (error as WebAPIRateLimitedError).code === ErrorCode.RateLimitedError;
|
|
}
|