Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions backend/src/api/public/alerts/alertOnce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { createHash } from 'crypto'
import type { Request } from 'express'

import { generateUUIDv4 } from '@crowd/common'
import { RedisCache } from '@crowd/redis'
import {
SlackChannel,
type SlackMessageSection,
SlackPersona,
sendSlackNotification,
} from '@crowd/slack'

const PATH_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi

export async function alertOnce(
req: Request,
{
status,
code,
message,
name,
context,
stack,
}: {
status: number
code: string
message: string
name?: string
context?: Record<string, unknown>
stack?: string
},
): Promise<void> {
if (status !== 409 && status < 500) return

const path = (req.originalUrl || req.url || '').split('?')[0]

// akrites alerts are handled separately, so skip them here.
if (path.startsWith('/v1/akrites') || path.startsWith('/v1/akrites-external')) {
return
}

const route = resolveRoute(req)

const dedupeKey = createHash('sha256')
.update(
[status, req.method, route, code, message, serializeContext(context)]
.filter((part) => part !== '')
.join(':'),
)
.digest('hex')

const cache = new RedisCache('public-api-alerts', req.redis, req.log)
const lease = generateUUIDv4()

try {
const held = await cache.setIfNotExistsOrGet(dedupeKey, lease, 60 * 60)
if (held !== lease) {
req.log.info({ dedupeKey }, 'Skipping duplicate public API alert')
return
}
} catch (err) {
req.log.warn({ err, dedupeKey }, 'Alert dedupe failed; sending anyway')
}

const sections: SlackMessageSection[] = [
{
title: 'Request',
text: `*Method:* \`${req.method}\`\n*URL:* \`${req.originalUrl || req.url}\``,
},
{
title: 'Error',
text: `*Code:* \`${code}\`\n*Name:* \`${name || code}\`\n*Message:* ${message}`,
},
]

if (context && Object.keys(context).length > 0) {
sections.push({
title: 'Context',
text: `\`\`\`${JSON.stringify(context, null, 2)}\`\`\``,
})
}

if (stack) {
sections.push({
title: 'Stack Trace',
text: `\`\`\`${stack.substring(0, 2700)}\`\`\``,
})
}

sendSlackNotification(
SlackChannel.CDP_PUBLIC_API_ALERTS,
status >= 500 ? SlackPersona.ERROR_REPORTER : SlackPersona.WARNING_PROPAGATOR,
status >= 500 ? `500 Error: ${name || message}` : `${status} Conflict: ${message}`,
sections,
)
}

function resolveRoute(req: Request): string {
if (req.route?.path != null) {
return `${req.baseUrl}${req.route.path}`
}
return (req.path || '').replace(PATH_UUID, ':id')
}

function serializeContext(context?: Record<string, unknown>): string {
if (!context) return ''

return Object.keys(context)
.sort()
.map((key) => {
const value = context[key]
if (Array.isArray(value)) {
return `${key}=${[...value].map(String).sort().join(',')}`
}
if (value !== null && typeof value === 'object') {
return `${key}=${JSON.stringify(value)}`
}
return `${key}=${String(value)}`
})
.join('|')
}
61 changes: 0 additions & 61 deletions backend/src/api/public/alerts/identityConflict.ts

This file was deleted.

28 changes: 0 additions & 28 deletions backend/src/api/public/alerts/memberResolveConflict.ts

This file was deleted.

39 changes: 0 additions & 39 deletions backend/src/api/public/alerts/notifyOnce.ts

This file was deleted.

48 changes: 23 additions & 25 deletions backend/src/api/public/middlewares/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import {
UnauthorizedError as Auth0UnauthorizedError,
} from 'express-oauth2-jwt-bearer'

import { HttpError, InsufficientScopeError, InternalError, UnauthorizedError } from '@crowd/common'
import { SlackChannel, SlackPersona, sendSlackNotification } from '@crowd/slack'
import {
ConflictError,
HttpError,
InsufficientScopeError,
InternalError,
UnauthorizedError,
} from '@crowd/common'

import { alertOnce } from '@/api/public/alerts/alertOnce'

/**
* Converts errors to structured JSON: `{ error: { code, message } }`.
Expand All @@ -18,6 +25,13 @@ export const errorHandler: ErrorRequestHandler = (
_next: NextFunction,
) => {
if (error instanceof HttpError) {
void alertOnce(req, {
status: error.status,
code: error.code,
message: error.message,
name: error.name,
context: error instanceof ConflictError ? error.context : undefined,
})
res.status(error.status).json(error.toJSON())
return
}
Expand Down Expand Up @@ -45,29 +59,13 @@ export const errorHandler: ErrorRequestHandler = (
'Unhandled error in public API',
)

sendSlackNotification(
SlackChannel.CDP_ALERTS,
SlackPersona.ERROR_REPORTER,
`Public API Error 500: ${req.method} ${req.url}`,
[
{
title: 'Request',
text: `*Method:* \`${req.method}\`\n*URL:* \`${req.url}\``,
},
{
title: 'Error',
text: `*Name:* \`${error?.name || 'Unknown'}\`\n*Message:* ${error?.message || 'No message'}`,
},
...(error?.stack
? [
{
title: 'Stack Trace',
text: `\`\`\`${error.stack.substring(0, 2700)}\`\`\``,
},
]
: []),
],
)
void alertOnce(req, {
status: 500,
code: 'INTERNAL_ERROR',
message: error?.message || 'No message',
name: error?.name || 'Unknown',
stack: error?.stack,
})

const unknownError = new InternalError()
res.status(unknownError.status).json(unknownError.toJSON())
Expand Down
9 changes: 2 additions & 7 deletions backend/src/api/public/v1/members/createMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { getProperDisplayName } from '@crowd/common'
import { createMember as insertMember, insertMemberIdentities } from '@crowd/data-access-layer'
import { MemberIdentityType } from '@crowd/types'

import { rethrowIdentityConflict } from '@/api/public/alerts/identityConflict'
import { optionsQx } from '@/database/sequelizeQueryExecutor'
import { created } from '@/utils/api'
import { rethrowDbConflict } from '@/utils/err'
Expand Down Expand Up @@ -62,15 +61,11 @@ export async function createMember(req: Request, res: Response): Promise<void> {

return { dbMember, dbIdentities }
} catch (error) {
// Only notify for a single identity because we can't tell which one conflicted in a batch.
if (identities.length === 1) {
const identity = identities[0]
rethrowIdentityConflict(req, error, {
return rethrowDbConflict(error, {
platform: identity.platform,
value:
identity.type === MemberIdentityType.EMAIL
? identity.value.trim().toLowerCase()
: identity.value.trim(),
value: identity.value,
Comment thread
skwowet marked this conversation as resolved.
type: identity.type,
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
} from '@crowd/data-access-layer'
import { IMemberIdentity, MemberIdentityType } from '@crowd/types'

import { rethrowIdentityConflict } from '@/api/public/alerts/identityConflict'
import { optionsQx } from '@/database/sequelizeQueryExecutor'
import { created, ok } from '@/utils/api'
import { rethrowDbConflict } from '@/utils/err'
import { validateOrThrow } from '@/utils/validation'

const paramsSchema = z.object({
Expand Down Expand Up @@ -101,8 +101,7 @@ export async function createMemberIdentity(req: Request, res: Response): Promise
}
}
} catch (error) {
rethrowIdentityConflict(req, error, {
memberId,
rethrowDbConflict(error, {
platform: data.platform,
value: data.value,
type: data.type,
Expand Down
Loading
Loading