diff --git a/src/api/StatusApi.test.ts b/src/api/StatusApi.test.ts new file mode 100644 index 0000000..dfb4396 --- /dev/null +++ b/src/api/StatusApi.test.ts @@ -0,0 +1,100 @@ +import { StatusApi, MutedAlert } from './StatusApi' +import { StatusRepository } from '../db/StatusRepository' + +describe('StatusApi.getAgeAlerts', () => { + // The repository is not used by getAgeAlerts, so a bare object is enough. + const statusApi = new StatusApi({} as StatusRepository) + + // A timestamp old enough to trigger a red alert + const staleDate = new Date(Date.now() - 100 * 60 * 60 * 1000) + + const yellowAgeHours = 26 + const redAgeHours = 50 + + it('creates a red alert for stale data when nothing is muted', () => { + const status = { + 3701: { latestUpdates: { 'anchor/playsByGender': staleDate } }, + } + + const alerts = statusApi.getAgeAlerts( + status, + yellowAgeHours, + redAgeHours + ) + + expect(alerts.red).toHaveLength(1) + expect(alerts.red?.[0]).toMatchObject({ + podcastId: 3701, + endpoint: 'anchor/playsByGender', + }) + }) + + it('suppresses alerts for a muted podcast/endpoint combination', () => { + const status = { + 3701: { latestUpdates: { 'anchor/playsByGender': staleDate } }, + } + const muted: MutedAlert[] = [ + { podcastId: 3701, endpoint: 'anchor/playsByGender' }, + ] + + const alerts = statusApi.getAgeAlerts( + status, + yellowAgeHours, + redAgeHours, + muted + ) + + expect(alerts.red).toBeUndefined() + expect(alerts.yellow).toBeUndefined() + }) + + it('only mutes the specified endpoint, not other endpoints of the podcast', () => { + const status = { + 3701: { + latestUpdates: { + 'anchor/playsByGender': staleDate, + 'anchor/plays': staleDate, + }, + }, + } + const muted: MutedAlert[] = [ + { podcastId: 3701, endpoint: 'anchor/playsByGender' }, + ] + + const alerts = statusApi.getAgeAlerts( + status, + yellowAgeHours, + redAgeHours, + muted + ) + + expect(alerts.red).toHaveLength(1) + expect(alerts.red?.[0]).toMatchObject({ + podcastId: 3701, + endpoint: 'anchor/plays', + }) + }) + + it('only mutes the specified podcast, not the same endpoint on other podcasts', () => { + const status = { + 3701: { latestUpdates: { 'anchor/playsByGender': staleDate } }, + 42: { latestUpdates: { 'anchor/playsByGender': staleDate } }, + } + const muted: MutedAlert[] = [ + { podcastId: 3701, endpoint: 'anchor/playsByGender' }, + ] + + const alerts = statusApi.getAgeAlerts( + status, + yellowAgeHours, + redAgeHours, + muted + ) + + expect(alerts.red).toHaveLength(1) + expect(alerts.red?.[0]).toMatchObject({ + podcastId: 42, + endpoint: 'anchor/playsByGender', + }) + }) +}) diff --git a/src/api/StatusApi.ts b/src/api/StatusApi.ts index 262be82..0681f43 100644 --- a/src/api/StatusApi.ts +++ b/src/api/StatusApi.ts @@ -1,6 +1,14 @@ import { StatusRepository } from '../db/StatusRepository' import { StatusPayload } from '../types/api' +// An alert that is intentionally muted, identified by the podcast (account id) +// and the endpoint identifier in the `provider/endpoint` format used by +// `getStatus` (e.g. `anchor/playsByGender`). +export type MutedAlert = { + podcastId: number + endpoint: string +} + class StatusApi { statusRepo: StatusRepository @@ -15,6 +23,10 @@ class StatusApi { // checks all dates and create yellow and red alerts if too old // returns an object with the alerts if there are any + // + // `mutedAlerts` lists podcast/endpoint combinations that should never + // generate an alert, e.g. for podcasts that only report a given endpoint + // sporadically and would otherwise produce noisy false positives. getAgeAlerts( statusData: { [podcastId: number]: { @@ -22,7 +34,8 @@ class StatusApi { } }, yellowAgeHours: number, - redAgeHours: number + redAgeHours: number, + mutedAlerts: MutedAlert[] = [] ) { type AlertData = { podcastId: number @@ -36,6 +49,16 @@ class StatusApi { const podcastId = parseInt(podcastIdStr) Object.entries(podcastData.latestUpdates).forEach( ([endpointName, endpointDate]) => { + // skip alerts that have been explicitly muted for this + // podcast/endpoint combination + const isMuted = mutedAlerts.some( + (muted) => + muted.podcastId === podcastId && + muted.endpoint === endpointName + ) + if (isMuted) { + return + } const ageHours = Math.round( (new Date().getTime() - new Date(endpointDate).getTime()) / diff --git a/src/api/mutedAlerts.ts b/src/api/mutedAlerts.ts new file mode 100644 index 0000000..99282e2 --- /dev/null +++ b/src/api/mutedAlerts.ts @@ -0,0 +1,18 @@ +import { MutedAlert } from './StatusApi' + +// Alerts that are intentionally muted in the `/status` age-alert check. +// +// Some podcasts only report certain endpoints sporadically. For those, stale +// data is expected and would otherwise produce noisy yellow/red alerts. Add an +// entry here to silence a single podcast/endpoint combination without affecting +// the rest of the podcast's monitoring (use the podcast-wide `monitored` flag +// in the `podcasts` table if you want to mute an entire podcast instead). +// +// `endpoint` uses the `provider/endpoint` identifier from `getStatus`, +// e.g. `anchor/playsByGender`. +export const MUTED_ALERTS: MutedAlert[] = [ + // account_id 3701: the show is not very active and only reports + // anchor/playsByGender occasionally, so the age check would otherwise fire + // false-positive alerts. Mute it. + { podcastId: 3701, endpoint: 'anchor/playsByGender' }, +] diff --git a/src/index.ts b/src/index.ts index 9a82a65..fc0a780 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { FeedbackRepository } from './db/FeedbackRepository' import { FeedbackApi } from './api/FeedbackApi' import { StatusRepository } from './db/StatusRepository' import { StatusApi } from './api/StatusApi' +import { MUTED_ALERTS } from './api/mutedAlerts' import crypto from 'crypto' import { body, validationResult } from 'express-validator' import { Config } from './config' @@ -429,7 +430,8 @@ app.get('/status', async (req: Request, res: Response, next: NextFunction) => { const alerts = statusApi.getAgeAlerts( status, yellowAgeHours, - redAgeHours + redAgeHours, + MUTED_ALERTS ) // the key yellow or red is is only set if there is an alert // this allows the client to check for the existence of the keys yellow and red