Skip to content

Commit 397d423

Browse files
authored
Feat/faster upload feedback (#1128)
### Description List - Bumped up ingest error reporting interval to every 15 minutes - Removed some fields from the emailed report ### Testing List - Code review Closes #1109 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a frequent ingest-error reporting cadence (every 15 minutes) alongside weekly summaries. * **Improvements** * Frequent reports use precise 15‑minute UTC windows and compact identifiers for clearer logs. * Weekly notifications remain for “alls well” or “no uploads.” * **Behavioral Changes** * Report emails are suppressed when errors occur. * **Tests** * Test suites updated with 15‑minute timestamp checks and revised expectations. * **Privacy** * Validation error payloads now omit address and contact fields. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c560a77 commit 397d423

9 files changed

Lines changed: 235 additions & 116 deletions

File tree

backend/compact-connect/lambdas/nodejs/ingest-event-reporter/lambda.ts

Lines changed: 100 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { JurisdictionClient } from '../lib/jurisdiction-client';
1111
import { IEventBridgeEvent } from '../lib/models/event-bridge-event-detail';
1212
import { IngestEventEmailService } from '../lib/email';
1313
import { EventClient } from '../lib/event-client';
14+
import { Compact, IJurisdiction } from 'lib/models';
1415

1516
const environmentVariables = new EnvironmentVariablesService();
1617
const logger = new Logger({ logLevel: environmentVariables.getLogLevel() });
@@ -59,8 +60,6 @@ export class Lambda implements LambdaInterface {
5960
logger.info('Processing event', { event: event });
6061
logger.debug('Context wait for event loop', { wait_for_empty_event_loop: context.callbackWaitsForEmptyEventLoop });
6162

62-
const [ startTimeStamp, endTimeStamp ] = this.eventClient.getYesterdayTimestamps();
63-
6463
// Loop over each compact the system knows about
6564
for (const compact of environmentVariables.getCompacts()) {
6665
let compactConfig;
@@ -78,90 +77,107 @@ export class Lambda implements LambdaInterface {
7877

7978
// Loop over each jurisdiction that we have contacts configured for
8079
for (const jurisdictionConfig of jurisdictionConfigs) {
81-
const ingestEvents = await this.eventClient.getEvents(
82-
compact, jurisdictionConfig.postalAbbreviation, startTimeStamp, endTimeStamp
83-
);
84-
85-
// If there were any issues, send a report email summarizing them
86-
if (ingestEvents.ingestFailures.length || ingestEvents.validationErrors.length) {
87-
const messageId = await this.emailService.sendReportEmail(
88-
ingestEvents,
89-
compactConfig.compactName,
90-
jurisdictionConfig.jurisdictionName,
91-
jurisdictionConfig.jurisdictionOperationsTeamEmails
92-
);
93-
94-
logger.info(
95-
'Sent event summary email',
96-
{
97-
compact: compact,
98-
jurisdiction: jurisdictionConfig.postalAbbreviation,
99-
message_id: messageId
100-
}
101-
);
102-
} else {
103-
logger.info(
104-
'No events in 24 hours',
105-
{
106-
compact: compact,
107-
jurisdiction: jurisdictionConfig.postalAbbreviation
108-
}
109-
);
110-
const eventType = event.eventType;
111-
112-
// If this is a weekly run and there have been no issues all week, we send an "All's Well" report
113-
if (eventType === 'weekly') {
114-
const [ weekStartStamp, weekEndStamp ] = this.eventClient.getLastWeekTimestamps();
115-
const weeklyIngestEvents = await this.eventClient.getEvents(
116-
compact,
117-
jurisdictionConfig.postalAbbreviation,
118-
weekStartStamp,
119-
weekEndStamp
120-
);
121-
122-
// verify that the jurisdiction uploaded licenses within the last week without any errors
123-
if (!weeklyIngestEvents.ingestFailures.length
124-
&& !weeklyIngestEvents.validationErrors.length
125-
&& weeklyIngestEvents.ingestSuccesses.length
126-
) {
127-
const messageId = await this.emailService.sendAllsWellEmail(
128-
compactConfig.compactName,
129-
jurisdictionConfig.jurisdictionName,
130-
jurisdictionConfig.jurisdictionOperationsTeamEmails
131-
);
132-
133-
logger.info(
134-
'Sent alls well email',
135-
{
136-
compact: compactConfig.compactName,
137-
jurisdiction: jurisdictionConfig.postalAbbreviation,
138-
message_id: messageId
139-
}
140-
);
141-
}
142-
else if(!weeklyIngestEvents.ingestSuccesses.length) {
143-
const messageId = await this.emailService.sendNoLicenseUpdatesEmail(
144-
compactConfig.compactName,
145-
jurisdictionConfig.jurisdictionName,
146-
[
147-
...jurisdictionConfig.jurisdictionOperationsTeamEmails,
148-
...compactConfig.compactOperationsTeamEmails
149-
]
150-
);
151-
152-
logger.warn(
153-
'No licenses uploaded withinin the last week',
154-
{
155-
compact: compactConfig.compactName,
156-
jurisdiction: jurisdictionConfig.postalAbbreviation,
157-
message_id: messageId
158-
}
159-
);
160-
}
161-
}
162-
}
80+
switch (event.eventType) {
81+
case 'weekly':
82+
await this.runWeeklyReports(compactConfig, jurisdictionConfig);
83+
break;
84+
default:
85+
// frequent case (every 15 minutes)
86+
await this.runFrequentReports(compactConfig, jurisdictionConfig);
87+
break;
88+
};
89+
16390
}
16491
}
16592
logger.info('Completing handler');
16693
}
94+
95+
public async runFrequentReports(compactConfig: Compact, jurisdictionConfig: IJurisdiction) {
96+
const [ startTimeStamp, endTimeStamp ] = this.eventClient.getLast15MinuteTimestamps();
97+
98+
const ingestEvents = await this.eventClient.getEvents(
99+
compactConfig.compactAbbr, jurisdictionConfig.postalAbbreviation, startTimeStamp, endTimeStamp
100+
);
101+
102+
// If there were any issues, send a report email summarizing them
103+
if (ingestEvents.ingestFailures.length || ingestEvents.validationErrors.length) {
104+
const messageId = await this.emailService.sendReportEmail(
105+
ingestEvents,
106+
compactConfig.compactName,
107+
jurisdictionConfig.jurisdictionName,
108+
jurisdictionConfig.jurisdictionOperationsTeamEmails
109+
);
110+
111+
logger.info(
112+
'Sent event summary email',
113+
{
114+
compact: compactConfig.compactAbbr,
115+
jurisdiction: jurisdictionConfig.postalAbbreviation,
116+
startTimeStamp,
117+
endTimeStamp,
118+
message_id: messageId
119+
}
120+
);
121+
} else {
122+
logger.info(
123+
'No events in window',
124+
{
125+
compact: compactConfig.compactAbbr,
126+
jurisdiction: jurisdictionConfig.postalAbbreviation,
127+
startTimeStamp,
128+
endTimeStamp
129+
}
130+
);
131+
}
132+
}
133+
134+
public async runWeeklyReports(compactConfig: Compact, jurisdictionConfig: IJurisdiction) {
135+
const [ weekStartStamp, weekEndStamp ] = this.eventClient.getLastWeekTimestamps();
136+
const weeklyIngestEvents = await this.eventClient.getEvents(
137+
compactConfig.compactAbbr,
138+
jurisdictionConfig.postalAbbreviation,
139+
weekStartStamp,
140+
weekEndStamp
141+
);
142+
143+
// verify that the jurisdiction uploaded licenses within the last week without any errors
144+
if (!weeklyIngestEvents.ingestFailures.length
145+
&& !weeklyIngestEvents.validationErrors.length
146+
&& weeklyIngestEvents.ingestSuccesses.length
147+
) {
148+
const messageId = await this.emailService.sendAllsWellEmail(
149+
compactConfig.compactName,
150+
jurisdictionConfig.jurisdictionName,
151+
jurisdictionConfig.jurisdictionOperationsTeamEmails
152+
);
153+
154+
logger.info(
155+
'Sent alls well email',
156+
{
157+
compact: compactConfig.compactName,
158+
jurisdiction: jurisdictionConfig.postalAbbreviation,
159+
message_id: messageId
160+
}
161+
);
162+
}
163+
else if(!weeklyIngestEvents.ingestSuccesses.length) {
164+
const messageId = await this.emailService.sendNoLicenseUpdatesEmail(
165+
compactConfig.compactName,
166+
jurisdictionConfig.jurisdictionName,
167+
[
168+
...jurisdictionConfig.jurisdictionOperationsTeamEmails,
169+
...compactConfig.compactOperationsTeamEmails
170+
]
171+
);
172+
173+
logger.warn(
174+
'No licenses uploaded within the last week',
175+
{
176+
compact: compactConfig.compactName,
177+
jurisdiction: jurisdictionConfig.postalAbbreviation,
178+
message_id: messageId
179+
}
180+
);
181+
}
182+
}
167183
}

backend/compact-connect/lambdas/nodejs/lib/event-client.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ export class EventClient {
2626
this.dynamoDBClient = props.dynamoDBClient;
2727
}
2828

29+
/*
30+
* Returns timestamps for the last complete 15-minute block
31+
* i.e. if now is 13:05, returns 12:45-13:00
32+
* if now is 13:15, returns 13:00-13:15
33+
*/
34+
public getLast15MinuteTimestamps(): [number, number] {
35+
const now: Date = new Date();
36+
const last15MinuteBlockStart: Date = new Date();
37+
const last15MinuteBlockEnd: Date = new Date();
38+
39+
// Calculate the start of the current 15-minute block
40+
const currentBlockStartMinutes = now.getUTCMinutes() - (now.getUTCMinutes() % 15);
41+
42+
last15MinuteBlockStart.setUTCMinutes(currentBlockStartMinutes, 0, 0);
43+
44+
// The end of the previous complete block is the start of the current block
45+
last15MinuteBlockEnd.setTime(last15MinuteBlockStart.getTime());
46+
47+
// The start of the previous complete block is 15 minutes before the end
48+
last15MinuteBlockStart.setUTCMinutes(currentBlockStartMinutes - 15, 0, 0);
49+
50+
return [
51+
Math.floor((last15MinuteBlockStart.valueOf()/1000)),
52+
Math.floor((last15MinuteBlockEnd.valueOf()/1000)),
53+
];
54+
}
55+
2956
/*
3057
* Returns timestamps for the beginning and end of the previous UTC day
3158
*/
@@ -40,12 +67,8 @@ export class EventClient {
4067
// Uncomment to manually force today's events into the time window (for development/testing)
4168
// today.setUTCDate(today.getUTCDate() + 1);
4269
return [
43-
Number.parseInt(
44-
(yesterday.valueOf()/1000).toString()
45-
),
46-
Number.parseInt(
47-
(today.valueOf()/1000).toString()
48-
)
70+
Math.floor((yesterday.valueOf()/1000)),
71+
Math.floor((today.valueOf()/1000)),
4972
];
5073
}
5174

@@ -63,12 +86,8 @@ export class EventClient {
6386
// Uncomment to manually force today's events into the time window (for development/testing)
6487
// today.setUTCDate(today.getUTCDate() + 1);
6588
return [
66-
Number.parseInt(
67-
(lastWeek.valueOf()/1000).toString()
68-
),
69-
Number.parseInt(
70-
(today.valueOf()/1000).toString()
71-
)
89+
Math.floor((lastWeek.valueOf()/1000)),
90+
Math.floor((today.valueOf()/1000)),
7291
];
7392
}
7493

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './event-records';
22
export * from './jurisdiction';
3+
export * from './compact';

backend/compact-connect/lambdas/nodejs/tests/ingest-event-reporter.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const mockSendNoLicenseUpdatesEmail = jest.fn().mockImplementation(
7373
(recipients: string[]) => Promise.resolve('message-id-no-license-updates')
7474
);
7575

76-
describe('Nightly runs', () => {
76+
describe('Frequent runs', () => {
7777
let mockSESClient: ReturnType<typeof mockClient>;
7878
let mockS3Client: ReturnType<typeof mockClient>;
7979
let lambda: Lambda;
@@ -459,7 +459,7 @@ describe('Weekly runs', () => {
459459
);
460460
});
461461

462-
it('should send a report email and not an alls well, when there were errors', async () => {
462+
it('should send nothing, when there were errors', async () => {
463463
const mockDynamoDBClient = mockClient(DynamoDBClient);
464464
const mockS3Client = mockClient(S3Client);
465465

@@ -523,8 +523,9 @@ describe('Weekly runs', () => {
523523
}
524524
);
525525

526-
// Verify an event report was sent
527-
expect(mockSendReportEmail).toHaveBeenCalled();
526+
// Verify an event report was not sent
527+
expect(mockSendReportEmail).not.toHaveBeenCalled();
528528
expect(mockSendAllsWellEmail).not.toHaveBeenCalled();
529+
expect(mockSendNoLicenseUpdatesEmail).not.toHaveBeenCalled();
529530
});
530531
});

backend/compact-connect/lambdas/nodejs/tests/lib/event-client.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,61 @@ describe('EventClient', () => {
7777
jest.clearAllMocks();
7878
});
7979

80+
it('should produce 15-minute timestamps 900 seconds (15 minutes) apart', async () => {
81+
const eventClient = new EventClient({
82+
logger: new Logger(),
83+
dynamoDBClient: asDynamoDBClient(mockDynamoDBClient)
84+
});
85+
86+
const [ startStamp, endStamp ] = eventClient.getLast15MinuteTimestamps();
87+
88+
expect(endStamp - startStamp).toEqual(900);
89+
});
90+
91+
it('should produce 15-minute blocks', async () => {
92+
const eventClient = new EventClient({
93+
logger: new Logger(),
94+
dynamoDBClient: asDynamoDBClient(mockDynamoDBClient)
95+
});
96+
97+
// Test case 1: if 'now' is at 11:01, it should return timestamps at 10:45-11:00
98+
jest.useFakeTimers();
99+
jest.setSystemTime(new Date('2025-01-01T11:01:00.000Z'));
100+
101+
const [ startStamp1, endStamp1 ] = eventClient.getLast15MinuteTimestamps();
102+
const expectedStart1 = Math.floor(new Date('2025-01-01T10:45:00.000Z').getTime() / 1000);
103+
const expectedEnd1 = Math.floor(new Date('2025-01-01T11:00:00.000Z').getTime() / 1000);
104+
105+
expect(startStamp1).toEqual(expectedStart1);
106+
expect(endStamp1).toEqual(expectedEnd1);
107+
expect(endStamp1 - startStamp1).toEqual(900); // 15 minutes (10:45 to 11:00)
108+
109+
// Test case 2: if 'now' is at 2025-01-01T00:00:00.001Z, it should return timestamps for 2024-12-31T23:45:00.000Z-2025-01-01T00:00:00.000Z
110+
jest.setSystemTime(new Date('2025-01-01T00:00:00.001Z'));
111+
112+
const [ startStamp2, endStamp2 ] = eventClient.getLast15MinuteTimestamps();
113+
const expectedStart2 = Math.floor(new Date('2024-12-31T23:45:00.000Z').getTime() / 1000);
114+
const expectedEnd2 = Math.floor(new Date('2025-01-01T00:00:00.000Z').getTime() / 1000);
115+
116+
expect(startStamp2).toEqual(expectedStart2);
117+
expect(endStamp2).toEqual(expectedEnd2);
118+
expect(endStamp2 - startStamp2).toEqual(900); // 15 minutes (23:45 to 00:00)
119+
120+
// Test case 3: if 'now' is at 12:35, it should return timestamps at 12:15-12:30
121+
jest.setSystemTime(new Date('2025-01-01T12:35:00.000Z'));
122+
123+
const [ startStamp3, endStamp3 ] = eventClient.getLast15MinuteTimestamps();
124+
const expectedStart3 = Math.floor(new Date('2025-01-01T12:15:00.000Z').getTime() / 1000);
125+
const expectedEnd3 = Math.floor(new Date('2025-01-01T12:30:00.000Z').getTime() / 1000);
126+
127+
expect(startStamp3).toEqual(expectedStart3);
128+
expect(endStamp3).toEqual(expectedEnd3);
129+
expect(endStamp3 - startStamp3).toEqual(900); // 15 minutes (12:15 to 12:30)
130+
131+
// Restore real timers
132+
jest.useRealTimers();
133+
});
134+
80135
it('should produce nightly timestamps 86400 seconds (24 hours) apart', async () => {
81136
const eventClient = new EventClient({
82137
logger: new Logger(),

0 commit comments

Comments
 (0)