From 45759d68bcd46566b1c5c1fae5fdcbee805c4f90 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Tue, 18 Feb 2025 14:54:16 -0500 Subject: [PATCH] improve(integrations/slack): sentry alert on error --- packages/web/app/src/lib/fetch-json.ts | 134 +++++++++++++ packages/web/app/src/lib/kit/errors.ts | 33 ++++ packages/web/app/src/lib/kit/helpers.ts | 17 ++ packages/web/app/src/lib/kit/index_.ts | 1 + packages/web/app/src/lib/slack-api.ts | 73 +++++++ packages/web/app/src/server/slack.ts | 240 +++++++++++++++++------- 6 files changed, 432 insertions(+), 66 deletions(-) create mode 100644 packages/web/app/src/lib/fetch-json.ts create mode 100644 packages/web/app/src/lib/kit/errors.ts create mode 100644 packages/web/app/src/lib/slack-api.ts diff --git a/packages/web/app/src/lib/fetch-json.ts b/packages/web/app/src/lib/fetch-json.ts new file mode 100644 index 0000000000..dd4b22bdeb --- /dev/null +++ b/packages/web/app/src/lib/fetch-json.ts @@ -0,0 +1,134 @@ +import { z } from 'zod'; +import { Kit } from './kit'; + +export async function fetchJson( + url: string, + requestInit?: RequestInit, + schema?: schema, +): Promise< + (schema extends z.ZodType ? z.infer : Kit.Json.Value) | FetchJsonErrors.FetchJsonErrors +> { + const response = await fetch(url, requestInit) + // @see https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#exceptions + .catch(Kit.oneOf(error => error instanceof TypeError || error instanceof DOMException)); + + if (response instanceof TypeError) { + return new FetchJsonErrors.FetchJsonRequestTypeError({ requestInit }, response); + } + + if (response instanceof DOMException) { + return new FetchJsonErrors.FetchJsonRequestNetworkError({}, response); + } + + const json = await response + .json() + .then(value => value as Kit.Json.Value) + // @see https://developer.mozilla.org/en-US/docs/Web/API/Response/json#exceptions + .catch( + Kit.oneOf( + error => + error instanceof SyntaxError || + error instanceof TypeError || + error instanceof DOMException, + ), + ); + + if (json instanceof DOMException) { + return new FetchJsonErrors.FetchJsonRequestNetworkError({}, json); + } + + if (json instanceof TypeError) { + return new FetchJsonErrors.FetchJsonResponseTypeError({ response }, json); + } + + if (json instanceof SyntaxError) { + return new FetchJsonErrors.FetchJsonResponseSyntaxError({ response }, json); + } + + if (schema) { + const result = schema.safeParse(json); + if (!result.success) { + return new FetchJsonErrors.FetchJsonResponseSchemaError( + { response, json, schema }, + result.error, + ); + } + return result.data as any; // z.infer>; + } + + return json as any; // Kit.Json.Value; +} + +// ================================= +// Error Classes +// ================================= + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace FetchJsonErrors { + export type FetchJsonErrors = FetchJsonResponseErrors | FetchJsonRequestErrors; + + // -------------------------------- + // Response Error Classes + // -------------------------------- + + export type FetchJsonRequestErrors = FetchJsonRequestTypeError | FetchJsonRequestNetworkError; + + export class FetchJsonRequestNetworkError extends Kit.Errors.ContextualError< + 'FetchJsonRequestNetworkError', + {}, + DOMException + > { + message = 'Network failure.'; + } + + export class FetchJsonRequestTypeError extends Kit.Errors.ContextualError< + 'FetchJsonRequestTypeError', + { requestInit?: RequestInit }, + TypeError + > { + message = 'Invalid request.'; + } + + // -------------------------------- + // Response Error Classes + // -------------------------------- + + export abstract class FetchJsonResponseError< + $Name extends string, + $Context extends { + response: Response; + }, + $Cause extends z.ZodError | SyntaxError | TypeError | DOMException, + > extends Kit.Errors.ContextualError<$Name, $Context, $Cause> { + message = 'Invalid response.'; + } + + export type FetchJsonResponseErrors = + | FetchJsonResponseSyntaxError + | FetchJsonResponseSchemaError + | FetchJsonResponseTypeError; + + export class FetchJsonResponseTypeError extends FetchJsonResponseError< + 'FetchJsonResponseTypeError', + { response: Response }, + TypeError + > { + message = 'Response is malformed.'; + } + + export class FetchJsonResponseSyntaxError extends FetchJsonResponseError< + 'FetchJsonResponseSyntaxError', + { response: Response }, + SyntaxError + > { + message = 'Response body is not valid JSON.'; + } + + export class FetchJsonResponseSchemaError extends FetchJsonResponseError< + 'FetchJsonResponseSchemaError', + { response: Response; json: Kit.Json.Value; schema: z.ZodType }, + z.ZodError + > { + message = 'Response body JSON violates the schema.'; + } +} diff --git a/packages/web/app/src/lib/kit/errors.ts b/packages/web/app/src/lib/kit/errors.ts new file mode 100644 index 0000000000..dc6b973385 --- /dev/null +++ b/packages/web/app/src/lib/kit/errors.ts @@ -0,0 +1,33 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Errors { + export abstract class ContextualError< + $Name extends string = string, + $Context extends object = object, + $Cause extends Error | undefined = Error | undefined, + > extends Error { + public name: $Name; + public context: $Context; + public cause: $Cause; + constructor( + ...args: undefined extends $Cause + ? [context: $Context, cause?: $Cause] + : [context: $Context, cause: $Cause] + ) { + const [context, cause] = args; + super('Something went wrong.', { cause }); + this.name = this.constructor.name as $Name; + this.context = context; + this.cause = cause as $Cause; + } + } + + export class TypedAggregateError<$Error extends Error> extends AggregateError { + constructor( + public errors: $Error[], + message?: string, + ) { + super(errors, message); + this.name = this.constructor.name; + } + } +} diff --git a/packages/web/app/src/lib/kit/helpers.ts b/packages/web/app/src/lib/kit/helpers.ts index bf375d2e56..c33403068d 100644 --- a/packages/web/app/src/lib/kit/helpers.ts +++ b/packages/web/app/src/lib/kit/helpers.ts @@ -8,3 +8,20 @@ export const tryOr = <$PrimaryResult, $FallbackResult>( return fallback(); } }; + +export const oneOf = ( + ...guards: OneOfCheck +): ((value: unknown) => type[number]) => { + return (value: unknown) => { + for (const guard of guards) { + if (guard(value)) { + return value; + } + } + throw new Error(`Unexpected value received by oneOf: ${value}`); + }; +}; + +type OneOfCheck = { + [index in keyof types]: (value: unknown) => value is types[index]; +}; diff --git a/packages/web/app/src/lib/kit/index_.ts b/packages/web/app/src/lib/kit/index_.ts index 187aed2b21..f104690377 100644 --- a/packages/web/app/src/lib/kit/index_.ts +++ b/packages/web/app/src/lib/kit/index_.ts @@ -3,3 +3,4 @@ export * from './types/headers'; export * from './helpers'; export * from './json'; export * from './zod-helpers'; +export * from './errors'; diff --git a/packages/web/app/src/lib/slack-api.ts b/packages/web/app/src/lib/slack-api.ts new file mode 100644 index 0000000000..fb9c85f75e --- /dev/null +++ b/packages/web/app/src/lib/slack-api.ts @@ -0,0 +1,73 @@ +import { stringify } from 'node:querystring'; +import { z } from 'zod'; +import { fetchJson } from './fetch-json'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace SlackAPI { + // ================================== + // Data Utilities + // ================================== + + export const createOauth2AuthorizeUrl = (parameters: { + state: string; + clientId: string; + redirectUrl: string; + scopes: string[]; + }) => { + const url = new URL('https://slack.com/oauth/v2/authorize'); + const searchParams = new URLSearchParams({ + scope: parameters.scopes.join(','), + client_id: parameters.clientId, + redirect_uri: parameters.redirectUrl, + state: parameters.state, + }); + + url.search = searchParams.toString(); + return url.toString(); + }; + + // ================================== + // Request Methods + // ================================== + + // ---------------------------------- + // OAuth2AccessResult + // ---------------------------------- + + const OAuth2AccessResult = z.discriminatedUnion('ok', [ + z.object({ + ok: z.literal(true), + access_token: z.string(), + }), + z.object({ + ok: z.literal(false), + error: z.string(), + }), + ]); + + export type OAuth2AccessResult = z.infer; + + export interface OAuth2AccessPayload { + clientId: string; + clientSecret: string; + code: string; + } + + export async function requestOauth2Access(payload: OAuth2AccessPayload) { + return fetchJson( + 'https://slack.com/api/oauth.v2.access', + { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: stringify({ + client_id: payload.clientId, + client_secret: payload.clientSecret, + code: payload.code, + }), + }, + OAuth2AccessResult, + ); + } +} diff --git a/packages/web/app/src/server/slack.ts b/packages/web/app/src/server/slack.ts index cf10289b48..a85920c27a 100644 --- a/packages/web/app/src/server/slack.ts +++ b/packages/web/app/src/server/slack.ts @@ -1,8 +1,12 @@ -import { stringify } from 'node:querystring'; import type { FastifyInstance } from 'fastify'; +import { GraphQLError } from 'graphql'; import { z } from 'zod'; import { env } from '@/env/backend'; import { graphql } from '@/gql'; +import { FetchJsonErrors } from '@/lib/fetch-json'; +import { Kit } from '@/lib/kit'; +import { SlackAPI } from '@/lib/slack-api'; +import * as Sentry from '@sentry/node'; import { graphqlRequest } from './utils'; const SlackIntegration_addSlackIntegration = graphql(/* GraphQL */ ` @@ -11,81 +15,112 @@ const SlackIntegration_addSlackIntegration = graphql(/* GraphQL */ ` } `); -const CallBackQuery = z.object({ - code: z.string({ - required_error: 'Invalid code', - }), - state: z.string({ - required_error: 'Invalid state', - }), -}); - -const ConnectParams = z.object({ - organizationSlug: z.string({ - required_error: 'Invalid organizationSlug', - }), -}); - -const SlackOAuthv2AccessResponse = z.discriminatedUnion('ok', [ - z.object({ - ok: z.literal(true), - access_token: z.string(), - }), - z.object({ - ok: z.literal(false), - error: z.string(), - }), -]); +const sentryTagsComponentLevel = { + component: 'slack', +}; export function connectSlack(server: FastifyInstance) { + // ---------------------------------- + // Callback + // ---------------------------------- + + const CallBackQuery = z.object({ + code: z.string({ + required_error: 'Invalid code', + }), + state: z.string({ + required_error: 'Invalid state', + }), + }); + server.get('/api/slack/callback', async (req, res) => { + const sentryTagsRouteLevel = { + ...sentryTagsComponentLevel, + route: '/api/slack/callback', + }; + if (env.slack === null) { - throw new Error('The Slack integration is not enabled.'); + const error = new SlackIntegrationErrors.DisabledError({}); + Sentry.captureException(error, { tags: sentryTagsRouteLevel }); + throw error; } const queryResult = CallBackQuery.safeParse(req.query); if (!queryResult.success) { - req.log.error('Received invalid data from Slack API.'); - void res.status(400).send(queryResult.error.flatten().fieldErrors); + const error = new SlackIntegrationErrors.SlackDefectCallbackSearchParametersError({ + fieldErrors: queryResult.error.flatten().fieldErrors, + }); + Sentry.captureException(error, { + level: 'warning', + tags: sentryTagsRouteLevel, + extra: error.context, + }); + req.log.warn(error.context, error.message); + void res.status(400).send(error.context.fieldErrors); return; } - const { code, state: organizationSlug } = queryResult.data; + const { code, state: organizationId } = queryResult.data; - req.log.info('Fetching data from Slack API (orgId=%s)', organizationSlug); + req.log.info('Fetching data from Slack API (orgId=%s)', organizationId); - const slackResponse = await fetch('https://slack.com/api/oauth.v2.access', { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - body: stringify({ - client_id: env.slack.clientId, - client_secret: env.slack.clientSecret, - code, - }), - }).then(res => res.json()); + const slackResult = await SlackAPI.requestOauth2Access({ + clientId: env.slack.clientId, + clientSecret: env.slack.clientSecret, + code, + }); - const slackResponseResult = SlackOAuthv2AccessResponse.safeParse(slackResponse); + if ( + slackResult instanceof FetchJsonErrors.FetchJsonRequestNetworkError || + slackResult instanceof FetchJsonErrors.FetchJsonRequestTypeError + ) { + const error = new SlackIntegrationErrors.SlackAPIRequestError( + { organizationId }, + slackResult, + ); + Sentry.captureException(error, { + level: 'error', + tags: sentryTagsRouteLevel, + extra: error.context, + }); + throw error; + } - if (!slackResponseResult.success) { - req.log.error('Error parsing data from Slack API (orgId=%s)', organizationSlug); - req.log.error(slackResponseResult.error.toString()); - void res.status(400).send('Failed to parse the response from Slack API'); + if (slackResult instanceof FetchJsonErrors.FetchJsonResponseError) { + const error = new SlackIntegrationErrors.SlackDefectResponseError( + { organizationId }, + slackResult, + ); + Sentry.captureException(error, { + level: 'warning', + tags: sentryTagsRouteLevel, + extra: { + ...error.context, + cause: slackResult.cause.message, + }, + }); + req.log.warn(error.context, error.message); + void res.status(400).send(error.message); return; } - if (!slackResponseResult.data.ok) { - req.log.error('Failed to retrieve access token from Slack API (orgId=%s)', organizationSlug); - req.log.error(slackResponseResult.data.error); - void res.status(400).send(slackResponseResult.data.error); + if (!slackResult.ok) { + const error = new SlackIntegrationErrors.TokenRetrieveError({ + organizationId, + slackErrorMessage: slackResult.error, + }); + Sentry.captureException(error, { + level: 'warning', + tags: sentryTagsRouteLevel, + extra: error.context, + }); + req.log.warn(error.context, error.message); + void res.status(400).send(slackResult.error); return; } - const token = slackResponseResult.data.access_token; - - const result = await graphqlRequest({ + const resultGraphql = await graphqlRequest({ url: env.graphqlPublicEndpoint, headers: { ...req.headers, @@ -97,25 +132,42 @@ export function connectSlack(server: FastifyInstance) { document: SlackIntegration_addSlackIntegration, variables: { input: { - organizationSlug, - token, + organizationSlug: organizationId, + token: slackResult.access_token, }, }, }); - if (result.errors) { - req.log.error('Failed setting slack token (orgId=%s)', organizationSlug); - for (const error of result.errors) { - req.log.error(error); + if (resultGraphql.errors) { + const resultGraphqlAggError = new Kit.Errors.TypedAggregateError([...resultGraphql.errors]); + const error = new SlackIntegrationErrors.APIRequestError( + { organizationId }, + resultGraphqlAggError, + ); + req.log.error(error.context, error.message); + // todo: add base error type with contextChain property + for (const errorCause of error.cause.errors) { + req.log.error(errorCause); } - throw new Error('Failed setting slack token.'); + throw error; } - void res.redirect(`/${organizationSlug}/view/settings`); + void res.redirect(`/${organizationId}/view/settings`); + }); + + // ---------------------------------- + // Connect + // ---------------------------------- + + const ConnectParams = z.object({ + organizationSlug: z.string({ + required_error: 'Invalid organizationSlug', + }), }); server.get('/api/slack/connect/:organizationSlug', async (req, res) => { req.log.info('Connect to Slack'); + if (env.slack === null) { req.log.error('The Slack integration is not enabled.'); throw new Error('The Slack integration is not enabled.'); @@ -130,9 +182,65 @@ export function connectSlack(server: FastifyInstance) { const { organizationSlug } = paramsResult.data; req.log.info('Connect organization to Slack (id=%s)', organizationSlug); - const slackUrl = `https://slack.com/oauth/v2/authorize?scope=incoming-webhook,chat:write,chat:write.public,commands&client_id=${env.slack.clientId}`; - const redirectUrl = `${env.appBaseUrl}/api/slack/callback`; - - void res.redirect(`${slackUrl}&state=${organizationSlug}&redirect_uri=${redirectUrl}`); + void res.redirect( + SlackAPI.createOauth2AuthorizeUrl({ + clientId: env.slack.clientId, + redirectUrl: `${env.appBaseUrl}/api/slack/callback`, + scopes: ['incoming-webhook', 'chat:write', 'chat:write.public', 'commands'], + state: organizationSlug, + }), + ); }); } + +// ================================= +// Error Classes +// ================================= + +// eslint-disable-next-line @typescript-eslint/no-namespace +namespace SlackIntegrationErrors { + export class DisabledError extends Kit.Errors.ContextualError<'DisabledError'> { + message = 'The Slack integration is not enabled.'; + } + + export class APIRequestError extends Kit.Errors.ContextualError< + 'APIRequestError', + { organizationId: string }, + Kit.Errors.TypedAggregateError + > { + message = 'API request to add Slack integration failed.'; + } + + export class SlackDefectCallbackSearchParametersError extends Kit.Errors.ContextualError< + 'SlackDefectCallbackSearchParametersError', + { fieldErrors: Record } + > { + message = 'Received invalid search parameters from Slack API.'; + } + + export class SlackAPIRequestError extends Kit.Errors.ContextualError< + 'SlackAPIRequestError', + { organizationId: string }, + FetchJsonErrors.FetchJsonRequestErrors + > { + message = 'Request to Slack API failed.'; + } + + export class TokenRetrieveError extends Kit.Errors.ContextualError< + 'TokenRetrieveError', + { + organizationId: string; + slackErrorMessage: string; + } + > { + message = 'Failed to retrieve access token from Slack API.'; + } + + export class SlackDefectResponseError extends Kit.Errors.ContextualError< + 'SlackDefectResponseError', + { organizationId: string }, + FetchJsonErrors.FetchJsonResponseErrors + > { + message = 'Received invalid response from Slack API.'; + } +}