diff --git a/.changeset/discord-webhook-alerts.md b/.changeset/discord-webhook-alerts.md new file mode 100644 index 00000000000..5c4c75c96cc --- /dev/null +++ b/.changeset/discord-webhook-alerts.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Add Discord as a first-class alert channel with Discord webhook formatting for alert notifications. diff --git a/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts new file mode 100644 index 00000000000..2a8b23eb660 --- /dev/null +++ b/packages/migrations/src/actions/2026.06.22T20-10-00.discord-webhook.ts @@ -0,0 +1,8 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.06.22T20-10-00.discord-webhook.ts', + run: ({ psql }) => psql` + ALTER TYPE alert_channel_type ADD VALUE 'DISCORD'; + `, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 5eec1962d92..0f1881fa596 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -122,6 +122,7 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT import('./actions/2026.05.29T00-00-00.schema-log-target-id-nullability'), import('./actions/2026.05.07T00-00-00.schema-version-promotion'), import('./actions/2026.06.05T00-00-00.metric-alert-filter-shared-only'), + import('./actions/2026.06.22T20-10-00.discord-webhook'), import('./actions/2026.06.25T00-00-00.failing-dangerous-change-types'), ]), }); diff --git a/packages/services/api/src/modules/alerts/index.ts b/packages/services/api/src/modules/alerts/index.ts index c697b7834ed..e925e14c7f1 100644 --- a/packages/services/api/src/modules/alerts/index.ts +++ b/packages/services/api/src/modules/alerts/index.ts @@ -1,5 +1,6 @@ import { createModule } from 'graphql-modules'; import { SavedFiltersStorage } from '../saved-filters/providers/saved-filters-storage'; +import { DiscordCommunicationAdapter } from './providers/adapters/discord'; import { TeamsCommunicationAdapter } from './providers/adapters/msteams'; import { SlackCommunicationAdapter } from './providers/adapters/slack'; import { WebhookCommunicationAdapter } from './providers/adapters/webhook'; @@ -22,5 +23,6 @@ export const alertsModule = createModule({ SlackCommunicationAdapter, WebhookCommunicationAdapter, TeamsCommunicationAdapter, + DiscordCommunicationAdapter, ], }); diff --git a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts index ff1d30feb73..27f82e254c1 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts @@ -10,6 +10,7 @@ export type AlertChannelMapper = AlertChannel; export type AlertSlackChannelMapper = AlertChannel; export type AlertWebhookChannelMapper = AlertChannel; export type TeamsWebhookChannelMapper = AlertChannel; +export type DiscordWebhookChannelMapper = AlertChannel; export type AlertMapper = Alert; export type MetricAlertRuleMapper = MetricAlertRule; export type MetricAlertRuleIncidentMapper = MetricAlertIncident; diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index 6c0083138d6..ada31655c45 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -25,6 +25,7 @@ export default gql` SLACK WEBHOOK MSTEAMS_WEBHOOK + DISCORD } enum AlertType { @@ -156,6 +157,13 @@ export default gql` endpoint: String! } + type DiscordWebhookChannel implements AlertChannel { + id: ID! + name: String! + type: AlertChannelType! + endpoint: String! + } + type Alert { id: ID! type: AlertType! diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts new file mode 100644 index 00000000000..1b2384e0939 --- /dev/null +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.spec.ts @@ -0,0 +1,297 @@ +import { AlertChannel } from 'packages/services/api/src/shared/entities'; +import { beforeEach, vi } from 'vitest'; +import { SchemaChangeType } from '@hive/storage'; +import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './common'; +import { DiscordCommunicationAdapter } from './discord'; + +const logger = { + child: () => ({ + debug: vi.fn(), + error: vi.fn(), + }), +}; + +const appBaseUrl = 'app-base-url'; +const webhookUrl = 'webhook-url'; + +function createAdapter() { + const httpClient = { + post: vi.fn().mockResolvedValue(undefined), + }; + const adapter = new DiscordCommunicationAdapter(logger as any, httpClient as any, appBaseUrl); + + return { adapter, httpClient }; +} + +describe('DiscordCommunicationAdapter', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('sendSchemaChangeNotification', () => { + it('should send schema change notification as Discord embed', async () => { + const changes = [ + { + id: 'id-1', + type: 'FIELD_REMOVED', + approvalMetadata: null, + criticality: 'BREAKING', + message: "Field 'addFoo' was removed from object type 'Mutation'", + meta: { + typeName: 'Mutation', + removedFieldName: 'addFoo', + isRemovedFieldDeprecated: false, + typeType: 'object type', + }, + path: 'Mutation.addFoo', + isSafeBasedOnUsage: false, + reason: + 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it.', + usageStatistics: null, + affectedAppDeployments: null, + breakingChangeSchemaCoordinate: 'Mutation.addFoo', + }, + { + id: 'id-2', + type: 'FIELD_ADDED', + approvalMetadata: null, + criticality: 'NON_BREAKING', + message: "Field 'addFooT' was added to object type 'Mutation'", + meta: { + typeName: 'Mutation', + addedFieldName: 'addFooT', + typeType: 'object type', + }, + path: 'Mutation.addFooT', + isSafeBasedOnUsage: false, + reason: null, + usageStatistics: null, + affectedAppDeployments: null, + breakingChangeSchemaCoordinate: null, + }, + ] as Array; + + const input = { + alert: { + id: 'alert-id', + type: 'SCHEMA_CHANGE_NOTIFICATIONS', + channelId: 'channel-id', + projectId: 'project-id', + organizationId: 'org-id', + createdAt: new Date().toISOString(), + targetId: 'target-id', + }, + integrations: { + slack: { + token: null, + }, + }, + event: { + organization: { + id: 'org-id', + cleanId: 'org-clean-id', + slug: 'org-clean-id', + name: '', + }, + project: { + id: 'project-id', + cleanId: 'project-clean-id', + slug: 'project-clean-id', + name: 'project-name', + }, + target: { + id: 'target-id', + cleanId: 'target-clean-id', + slug: 'target-clean-id', + name: 'target-name', + }, + changes, + messages: [], + initial: false, + errors: [], + schema: { + id: 'schema-id', + commit: 'commit', + valid: true, + }, + }, + channel: { + webhookEndpoint: webhookUrl, + } as AlertChannel, + } as SchemaChangeNotificationInput; + + const { adapter } = createAdapter(); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendSchemaChangeNotification(input); + + expect(sendDiscordMessageSpy).toHaveBeenCalledWith(webhookUrl, { + embeds: [ + expect.objectContaining({ + title: 'Breaking schema changes detected', + url: 'app-base-url/org-clean-id/project-clean-id/target-clean-id/history/schema-id', + color: 0xe74c3c, + description: expect.stringContaining('### Breaking changes'), + fields: [ + { name: 'Project', value: 'project-name', inline: true }, + { name: 'Target', value: 'target-name', inline: true }, + { name: 'Commit', value: 'commit', inline: true }, + ], + }), + ], + }); + expect(sendDiscordMessageSpy.mock.calls[0]?.[1].embeds?.[0]?.description).toContain( + 'Field `addFoo` was removed from object type `Mutation`', + ); + expect(sendDiscordMessageSpy.mock.calls[0]?.[1].embeds?.[0]?.description).toContain( + '### Safe changes', + ); + }); + + it('should not send a notification without a webhook endpoint', async () => { + const input = { + event: { + organization: { id: 'org-id' }, + project: { id: 'project-id' }, + target: { id: 'target-id' }, + }, + channel: {}, + } as SchemaChangeNotificationInput; + const { adapter } = createAdapter(); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendSchemaChangeNotification(input); + + expect(sendDiscordMessageSpy).not.toHaveBeenCalled(); + }); + }); + + describe('sendChannelConfirmation', () => { + it('should send channel confirmation', async () => { + const input = { + event: { + organization: { + id: 'org-id', + cleanId: 'org-clean-id', + slug: 'org-clean-id', + }, + project: { + id: 'project-id', + cleanId: 'project-clean-id', + slug: 'project-clean-id', + name: 'project-name', + }, + kind: 'created', + }, + channel: { + webhookEndpoint: webhookUrl, + }, + } as ChannelConfirmationInput; + const { adapter } = createAdapter(); + const sendDiscordMessageSpy = vi.spyOn(adapter, 'sendDiscordMessage'); + + await adapter.sendChannelConfirmation(input); + + expect(sendDiscordMessageSpy).toHaveBeenCalledWith(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: 0x5865f2, + description: + 'I will send notifications here about your [project-name](app-base-url/org-clean-id/project-clean-id) project.', + }, + ], + }); + + input.event.kind = 'deleted'; + await adapter.sendChannelConfirmation(input); + + expect(sendDiscordMessageSpy).toHaveBeenLastCalledWith(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: 0x5865f2, + description: + 'I will no longer send notifications here about your [project-name](app-base-url/org-clean-id/project-clean-id) project.', + }, + ], + }); + }); + }); + + describe('sendDiscordMessage', () => { + it('sends a Discord webhook payload with embeds', async () => { + const { adapter, httpClient } = createAdapter(); + + await adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [ + { + title: 'Notification', + description: 'Schema changed', + color: 0x2ecc71, + }, + ], + }); + + expect(httpClient.post).toHaveBeenCalledWith( + 'http://example.com/webhook', + expect.objectContaining({ + headers: { + 'Content-Type': 'application/json', + }, + json: { + username: 'GraphQL Hive', + embeds: [ + { + title: 'Notification', + description: 'Schema changed', + color: 0x2ecc71, + }, + ], + allowed_mentions: { parse: [] }, + }, + context: { + logger: expect.any(Object), + }, + }), + ); + }); + + it('truncates embed fields to Discord limits', async () => { + const { adapter, httpClient } = createAdapter(); + + await adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [ + { + title: 'a'.repeat(300), + description: 'b'.repeat(5000), + fields: [ + { + name: 'c'.repeat(300), + value: 'd'.repeat(2000), + }, + ], + }, + ], + }); + + const body = httpClient.post.mock.calls[0][1].json; + + expect(body.embeds[0].title).toHaveLength(256); + expect(body.embeds[0].description).toHaveLength(4096); + expect(body.embeds[0].fields[0].name).toHaveLength(256); + expect(body.embeds[0].fields[0].value).toHaveLength(1024); + }); + + it('handles failed send operation', async () => { + const { adapter, httpClient } = createAdapter(); + httpClient.post.mockRejectedValueOnce(new Error('Failed to send Discord message: Bad Request')); + + await expect( + adapter.sendDiscordMessage('http://example.com/webhook', { + embeds: [{ title: 'Test message' }], + }), + ).rejects.toThrow('Failed to send Discord message: Bad Request'); + }); + }); +}); diff --git a/packages/services/api/src/modules/alerts/providers/adapters/discord.ts b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts new file mode 100644 index 00000000000..c7f8ea0f52e --- /dev/null +++ b/packages/services/api/src/modules/alerts/providers/adapters/discord.ts @@ -0,0 +1,274 @@ +import { Inject, Injectable } from 'graphql-modules'; +import { CriticalityLevel } from '@graphql-inspector/core'; +import { SchemaChangeType } from '@hive/storage'; +import { HttpClient } from '../../../shared/providers/http-client'; +import { Logger } from '../../../shared/providers/logger'; +import { WEB_APP_URL } from '../../../shared/providers/tokens'; +import { + ChannelConfirmationInput, + CommunicationAdapter, + createMDLink, + SchemaChangeNotificationInput, + slackCoderize, +} from './common'; + +const DISCORD_RED = 0xe74c3c; +const DISCORD_GREEN = 0x2ecc71; +const DISCORD_BLUE = 0x5865f2; + +const DISCORD_MAX_EMBEDS = 10; +const DISCORD_MAX_TITLE_LENGTH = 256; +const DISCORD_MAX_DESCRIPTION_LENGTH = 4096; +const DISCORD_MAX_FIELDS = 25; +const DISCORD_MAX_FIELD_NAME_LENGTH = 256; +const DISCORD_MAX_FIELD_VALUE_LENGTH = 1024; + +type DiscordWebhookPayload = { + username?: string; + content?: string; + embeds?: DiscordEmbed[]; + allowed_mentions?: { + parse: string[]; + }; +}; + +type DiscordEmbed = { + title?: string; + description?: string; + url?: string; + color?: number; + fields?: DiscordEmbedField[]; +}; + +type DiscordEmbedField = { + name: string; + value: string; + inline?: boolean; +}; + +@Injectable() +export class DiscordCommunicationAdapter implements CommunicationAdapter { + private logger: Logger; + + constructor( + logger: Logger, + private httpClient: HttpClient, + @Inject(WEB_APP_URL) private appBaseUrl: string, + ) { + this.logger = logger.child({ service: 'DiscordCommunicationAdapter' }); + } + + async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { + this.logger.debug( + `Sending Schema Change Notifications over Discord (organization=%s, project=%s, target=%s)`, + input.event.organization.id, + input.event.project.id, + input.event.target.id, + ); + + const webhookUrl = input.channel.webhookEndpoint; + + if (!webhookUrl) { + this.logger.debug(`Discord Integration is not available`); + return; + } + + try { + const totalChanges = input.event.changes.length + input.event.messages.length; + const projectLink = createMDLink({ + text: input.event.project.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}`, + }); + const targetLink = createMDLink({ + text: input.event.target.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}/${input.event.target.slug}`, + }); + const changeUrl = `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}/${input.event.target.slug}/history/${input.event.schema.id}`; + const viewLink = createMDLink({ + text: 'view details', + url: changeUrl, + }); + + const message = input.event.initial + ? `Hive received your first schema in project ${projectLink}, target ${targetLink} (${viewLink}).` + : `Hive found **${totalChanges} ${this.pluralize( + 'change', + totalChanges, + )}** in project ${projectLink}, target ${targetLink} (${viewLink}).`; + + const detailsText = input.event.initial + ? '' + : createDetailsText(input.event.changes, input.event.messages, input.event.errors); + + const hasBreakingChanges = input.event.changes.some( + change => change.criticality === CriticalityLevel.Breaking, + ); + const hasErrors = input.event.errors.length > 0; + + await this.sendDiscordMessage(webhookUrl, { + embeds: [ + { + title: input.event.initial + ? 'Schema published' + : hasBreakingChanges || hasErrors + ? 'Breaking schema changes detected' + : 'Schema changes detected', + url: changeUrl, + color: hasBreakingChanges || hasErrors ? DISCORD_RED : DISCORD_GREEN, + description: [message, detailsText].filter(Boolean).join('\n\n'), + fields: [ + { + name: 'Project', + value: input.event.project.name || input.event.project.slug, + inline: true, + }, + { + name: 'Target', + value: input.event.target.name || input.event.target.slug, + inline: true, + }, + { + name: 'Commit', + value: input.event.schema.commit || '—', + inline: true, + }, + ], + }, + ], + }); + } catch (error) { + this.logger.error('Failed to send Discord notification (error=%o)', error); + } + } + + async sendChannelConfirmation(input: ChannelConfirmationInput) { + this.logger.debug( + `Sending Channel Confirmation over Discord (organization=%s, project=%s, channel=%s)`, + input.event.organization.id, + input.event.project.id, + ); + + const webhookUrl = input.channel.webhookEndpoint; + + if (!webhookUrl) { + this.logger.debug(`Discord Integration is not available`); + return; + } + + const actionMessage = + input.event.kind === 'created' + ? `I will send notifications here` + : `I will no longer send notifications here`; + + try { + const projectLink = createMDLink({ + text: input.event.project.name, + url: `${this.appBaseUrl}/${input.event.organization.slug}/${input.event.project.slug}`, + }); + + await this.sendDiscordMessage(webhookUrl, { + embeds: [ + { + title: 'GraphQL Hive notifications', + color: DISCORD_BLUE, + description: `${actionMessage} about your ${projectLink} project.`, + }, + ], + }); + } catch (error) { + this.logger.error('Failed to send Discord notification (error=%o)', error); + } + } + + private pluralize(word: string, num: number): string { + return word + (num !== 1 ? 's' : ''); + } + + async sendDiscordMessage(webhookUrl: string, payload: DiscordWebhookPayload) { + const normalizedPayload: DiscordWebhookPayload = { + username: 'GraphQL Hive', + ...payload, + allowed_mentions: payload.allowed_mentions ?? { parse: [] }, + embeds: payload.embeds?.slice(0, DISCORD_MAX_EMBEDS).map(limitEmbed), + }; + + await this.httpClient.post(webhookUrl, { + headers: { + 'Content-Type': 'application/json', + }, + json: normalizedPayload, + context: { + logger: this.logger, + }, + }); + } +} + +function createDetailsText( + changes: readonly SchemaChangeType[], + messages: readonly string[], + errors: readonly { message: string }[], +): string { + const breakingChanges = changes.filter( + change => change.criticality === CriticalityLevel.Breaking, + ); + const safeChanges = changes.filter(change => change.criticality !== CriticalityLevel.Breaking); + + const sections: string[] = []; + + if (breakingChanges.length) { + sections.push(renderChangeList('Breaking changes', breakingChanges)); + } + + if (safeChanges.length) { + sections.push(renderChangeList('Safe changes', safeChanges)); + } + + if (messages.length) { + sections.push(`### Other changes\n${messages.map(message => slackCoderize(message)).join('\n')}`); + } + + if (errors.length) { + sections.push(`### Errors\n${errors.map(error => slackCoderize(error.message)).join('\n')}`); + } + + return sections.join('\n'); +} + +function renderChangeList(title: string, changes: readonly SchemaChangeType[]): string { + const text = changes + .map(change => { + let text = ` - ${change.message}`; + if (change.isSafeBasedOnUsage) { + text += ' (safe based on usage)'; + } + + return slackCoderize(text); + }) + .join('\n'); + + return `### ${title}\n${text}`; +} + +function limitEmbed(embed: DiscordEmbed): DiscordEmbed { + return { + ...embed, + title: embed.title ? truncate(embed.title, DISCORD_MAX_TITLE_LENGTH) : undefined, + description: embed.description + ? truncate(embed.description, DISCORD_MAX_DESCRIPTION_LENGTH) + : undefined, + fields: embed.fields?.slice(0, DISCORD_MAX_FIELDS).map(field => ({ + ...field, + name: truncate(field.name, DISCORD_MAX_FIELD_NAME_LENGTH), + value: truncate(field.value, DISCORD_MAX_FIELD_VALUE_LENGTH), + })), + }; +} + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength - 3) + '...'; +} diff --git a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts index ebf0ba2b8b9..57146734bdb 100644 --- a/packages/services/api/src/modules/alerts/providers/alerts-manager.ts +++ b/packages/services/api/src/modules/alerts/providers/alerts-manager.ts @@ -10,6 +10,7 @@ import { Logger } from '../../shared/providers/logger'; import type { ProjectSelector } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; import { ChannelConfirmationInput, SchemaChangeNotificationInput } from './adapters/common'; +import { DiscordCommunicationAdapter } from './adapters/discord'; import { TeamsCommunicationAdapter } from './adapters/msteams'; import { SlackCommunicationAdapter } from './adapters/slack'; import { WebhookCommunicationAdapter } from './adapters/webhook'; @@ -28,6 +29,7 @@ export class AlertsManager { private slack: SlackCommunicationAdapter, private webhook: WebhookCommunicationAdapter, private teamsWebhook: TeamsCommunicationAdapter, + private discordWebhook: DiscordCommunicationAdapter, private organizationManager: OrganizationManager, private projectManager: ProjectManager, private storage: Storage, @@ -311,6 +313,14 @@ export class AlertsManager { integrations, }); } + if (channel.type === 'DISCORD') { + return this.discordWebhook.sendSchemaChangeNotification({ + event: safeEvent, + alert, + channel, + integrations, + }); + } return this.webhook.sendSchemaChangeNotification({ event: safeEvent, @@ -377,6 +387,8 @@ export class AlertsManager { await this.slack.sendChannelConfirmation(channelConfirmationContext); } else if (channel.type === 'MSTEAMS_WEBHOOK') { await this.teamsWebhook.sendChannelConfirmation(channelConfirmationContext); + } else if (channel.type === 'DISCORD') { + await this.discordWebhook.sendChannelConfirmation(channelConfirmationContext); } else { await this.webhook.sendChannelConfirmation(); } diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index 36ea8aa6fde..a6b8fb7312e 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -142,7 +142,7 @@ const METRIC_ALERT_INCIDENT_SELECT = psql` const AlertChannelRowSchema = zod.object({ id: zod.string(), projectId: zod.string(), - type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK']), + type: zod.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD']), name: zod.string(), createdAt: zod.string(), slackChannel: zod.string().nullable(), diff --git a/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts new file mode 100644 index 00000000000..5b5653ff353 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/DiscordWebhookChannel.ts @@ -0,0 +1,10 @@ +import type { DiscordWebhookChannelResolvers } from './../../../__generated__/types'; + +export const DiscordWebhookChannel: DiscordWebhookChannelResolvers = { + __isTypeOf: channel => { + return channel.type === 'DISCORD'; + }, + endpoint: async channel => { + return channel.webhookEndpoint ?? ''; + }, +}; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 5511b596dbc..57b8fa0f73d 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -7,7 +7,7 @@ * */ -export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; +export type alert_channel_type = 'DISCORD' | 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS'; export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT'; export type hive_subgraph_log_type = 'added' | 'changed' | 'removed' | 'unchanged'; diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index a1194a60c9c..d8588ef391b 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4575,7 +4575,7 @@ const OrganizationBillingModel = z.object({ const AlertChannelModel = z.object({ id: z.string(), name: z.string(), - type: z.enum(['MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), + type: z.enum(['DISCORD', 'MSTEAMS_WEBHOOK', 'SLACK', 'WEBHOOK']), projectId: z.string(), createdAt: z.string(), slackChannel: z.string().nullable(), diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts index cad8c550d9d..613fd0f86c8 100644 --- a/packages/services/workflows/src/lib/metric-alert-notifier.ts +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -7,7 +7,7 @@ import { sendWebhook, type RequestBroker } from './webhooks/send-webhook.js'; export type AlertChannelRow = { id: string; - type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK'; + type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD'; name: string; slackChannel: string | null; webhookEndpoint: string | null; @@ -198,6 +198,61 @@ function severityColor(severity: NotificationEvent['rule']['severity']): string return SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.WARNING; } +export async function sendDiscordNotification(args: { + channel: AlertChannelRow; + event: NotificationEvent; + requestBroker: RequestBroker | null; + logger: Logger; + idempotencyKey: string; + attempt: number; + maxAttempts: number; +}) { + const { channel, event, logger } = args; + + if (!channel.webhookEndpoint) { + logger.warn({ channelId: channel.id }, 'Discord webhook endpoint not configured'); + return; + } + + const isFiring = event.state === 'firing'; + const emoji = isFiring ? '🔴' : '✅'; + const action = isFiring ? 'triggered' : 'resolved'; + const color = Number.parseInt(isFiring ? severityColor(event.rule.severity) : RESOLVED_COLOR, 16); + + const changeText = formatChangeText(event); + + const payload = { + username: 'GraphQL Hive', + allowed_mentions: { parse: [] }, + embeds: [ + { + title: truncate(`${emoji} ${event.rule.name} — ${action}`, 256), + color, + description: truncate(changeText, 4096), + fields: [ + { name: 'Type', value: event.rule.type, inline: true }, + { name: 'Severity', value: event.rule.severity, inline: true }, + { + name: 'Target', + value: truncate(`${event.targetSlug} in ${event.projectSlug}`, 1024), + inline: false, + }, + ], + }, + ], + }; + + await sendWebhook(logger, args.requestBroker, { + attempt: args.attempt, + maxAttempts: args.maxAttempts, + endpoint: channel.webhookEndpoint, + data: payload, + headers: { 'Idempotency-Key': args.idempotencyKey }, + }); + + logger.debug({ channelId: channel.id }, 'Discord notification sent'); +} + function formatChangeText(event: NotificationEvent): string { const { rule, currentValue, previousValue } = event; const unit = rule.type === 'LATENCY' ? 'ms' : rule.type === 'ERROR_RATE' ? '%' : ' requests'; @@ -246,3 +301,11 @@ function buildWebhookPayload(event: NotificationEvent) { organization: { slug: event.organizationSlug }, }; } + +function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength - 3) + '...'; +} diff --git a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts index 2182e9caa2c..07f14926ff5 100644 --- a/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts +++ b/packages/services/workflows/src/tasks/send-metric-alert-channel-notification.ts @@ -3,6 +3,7 @@ import { psql } from '@hive/postgres'; import { SpanKind, SpanStatusCode, trace } from '@hive/service-common'; import { defineTask, implementTask } from '../kit.js'; import { + sendDiscordNotification, sendSlackNotification, sendTeamsNotification, sendWebhookNotification, @@ -39,7 +40,7 @@ const HydrationRowSchema = z.object({ organizationId: z.string(), // channel channelId: z.string(), - channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK']), + channelType: z.enum(['SLACK', 'WEBHOOK', 'MSTEAMS_WEBHOOK', 'DISCORD']), channelName: z.string(), slackChannel: z.string().nullable(), webhookEndpoint: z.string().nullable(), @@ -205,6 +206,17 @@ export const task = implementTask(SendMetricAlertChannelNotificationTask, async maxAttempts: helpers.job.max_attempts, }); break; + case 'DISCORD': + await sendDiscordNotification({ + channel, + event, + requestBroker: context.requestBroker, + logger, + idempotencyKey, + attempt: helpers.job.attempts, + maxAttempts: helpers.job.max_attempts, + }); + break; } // Record successful dispatch. ON CONFLICT covers the rare case where a diff --git a/packages/web/app/src/components/project/alerts/channels-table.tsx b/packages/web/app/src/components/project/alerts/channels-table.tsx index bdf749cd4b2..54b75fc98ce 100644 --- a/packages/web/app/src/components/project/alerts/channels-table.tsx +++ b/packages/web/app/src/components/project/alerts/channels-table.tsx @@ -17,6 +17,9 @@ export const ChannelsTable_AlertChannelFragment = graphql(` ... on TeamsWebhookChannel { endpoint } + ... on DiscordWebhookChannel { + endpoint + } } `); @@ -24,6 +27,7 @@ const colorMap = { [AlertChannelType.Slack]: 'green' as const, [AlertChannelType.Webhook]: 'yellow' as const, [AlertChannelType.MsteamsWebhook]: 'orange' as const, + [AlertChannelType.Discord]: 'blue' as const, }; export function ChannelsTable(props: { @@ -39,7 +43,8 @@ export function ChannelsTable(props: { } if ( channel.__typename === 'AlertWebhookChannel' || - channel.__typename === 'TeamsWebhookChannel' + channel.__typename === 'TeamsWebhookChannel' || + channel.__typename === 'DiscordWebhookChannel' ) { return channel.endpoint; } diff --git a/packages/web/app/src/components/project/alerts/create-channel.tsx b/packages/web/app/src/components/project/alerts/create-channel.tsx index 008757005a6..9cc01b70ea1 100644 --- a/packages/web/app/src/components/project/alerts/create-channel.tsx +++ b/packages/web/app/src/components/project/alerts/create-channel.tsx @@ -87,9 +87,35 @@ export const CreateChannelModal = ({ } }, }); - const isWebhookLike = [AlertChannelType.Webhook, AlertChannelType.MsteamsWebhook].includes( - values.type, - ); + const isWebhookLike = [ + AlertChannelType.Webhook, + AlertChannelType.MsteamsWebhook, + AlertChannelType.Discord, + ].includes(values.type); + + const endpointHelp = (() => { + if (values.endpoint) { + return

Hive will send alerts to your endpoint.

; + } + + if (values.type === AlertChannelType.MsteamsWebhook) { + return ( + + Follow this guide to set up an incoming webhook connector in MS Teams + + ); + } + + if (values.type === AlertChannelType.Discord) { + return ( + + Follow this guide to set up a Discord webhook + + ); + } + + return

Hive will send alerts to your endpoint.

; + })(); return ( @@ -135,6 +161,7 @@ export const CreateChannelModal = ({ { value: AlertChannelType.Slack, name: 'Slack' }, { value: AlertChannelType.Webhook, name: 'Webhook' }, { value: AlertChannelType.MsteamsWebhook, name: 'MS Teams Webhook' }, + { value: AlertChannelType.Discord, name: 'Discord Webhook' }, ]} /> {touched.type && errors.type &&
{errors.type}
} @@ -163,13 +190,7 @@ export const CreateChannelModal = ({ {mutation.data.addAlertChannel.error.inputErrors.webhookEndpoint} )} - {values.endpoint ? ( -

Hive will send alerts to your endpoint.

- ) : ( - - Follow this guide to set up an incoming webhook connector in MS Teams - - )} + {endpointHelp} )} diff --git a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx index 0475179d53b..f012e901803 100644 --- a/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx +++ b/packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx @@ -54,6 +54,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', + [AlertChannelType.Discord]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string { diff --git a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx index 2d22b39cc79..ab07ace1426 100644 --- a/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx +++ b/packages/web/app/src/components/target/alerts/alert-notification-preview.tsx @@ -71,7 +71,7 @@ type PreviewProps = { direction: string; thresholdType: string; thresholdValue: string; - channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | null; + channelType: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK' | 'DISCORD' | null; targetSlug: string; projectSlug: string; }; @@ -261,6 +261,51 @@ function TeamsPreview(props: PreviewProps) { ); } +function DiscordPreview(props: PreviewProps) { + const colors = + SEVERITY_COLORS[props.severity as keyof typeof SEVERITY_COLORS] ?? SEVERITY_COLORS.WARNING; + const threshold = formatThreshold( + props.alertType, + props.direction, + props.thresholdValue, + props.thresholdType, + ); + + return ( +
+
Discord preview
+
+ {/* Discord embeds render the severity color as a vertical bar. */} +
+
+
+ 🔴 {props.alertName || 'Untitled alert'} — triggered +
+
+ {notificationMetricLabel(props.alertType, props.metricLabel)} {threshold} +
+
+
+ Type + {props.alertType} +
+
+ Severity + {props.severity} +
+
+ Target + + {props.targetSlug} in {props.projectSlug} + +
+
+
+
+
+ ); +} + export function AlertPreview(props: PreviewProps) { if (!props.channelType) { return ( @@ -281,6 +326,9 @@ export function AlertPreview(props: PreviewProps) { case 'MSTEAMS_WEBHOOK': preview = ; break; + case 'DISCORD': + preview = ; + break; default: return null; } diff --git a/packages/web/app/src/components/v2/tag.tsx b/packages/web/app/src/components/v2/tag.tsx index f567c38e712..99c2ca89432 100644 --- a/packages/web/app/src/components/v2/tag.tsx +++ b/packages/web/app/src/components/v2/tag.tsx @@ -2,6 +2,7 @@ import { ReactElement, ReactNode } from 'react'; import { cn } from '@/lib/utils'; const colors = { + blue: 'bg-blue-500/10 text-blue-500', green: 'bg-green-500/10 text-green-500', yellow: 'bg-yellow-500/10 text-yellow-500', gray: 'bg-neutral-10/10 text-neutral-10', diff --git a/packages/web/app/src/pages/target-alerts-rules.tsx b/packages/web/app/src/pages/target-alerts-rules.tsx index fe0b86d6e84..9ddf888a3ab 100644 --- a/packages/web/app/src/pages/target-alerts-rules.tsx +++ b/packages/web/app/src/pages/target-alerts-rules.tsx @@ -111,6 +111,7 @@ const CHANNEL_TYPE_LABEL: Record = { [AlertChannelType.Slack]: 'Slack', [AlertChannelType.Webhook]: 'Webhook', [AlertChannelType.MsteamsWebhook]: 'MS Teams', + [AlertChannelType.Discord]: 'Discord', }; function destinationLabel(channels: ReadonlyArray<{ type: string }>): string {