Skip to content

Commit 785502e

Browse files
committed
feat(api-graphql): add WebSocket connection health monitoring
- Add ConnectionHealthMonitor class to track keep-alive messages - Record connection establishment and keep-alive timestamps - Provide health check API with configurable thresholds - Dispatch Hub events for monitoring integration - Add comprehensive test coverage This addresses the need for WebSocket health monitoring without performance-impacting workarounds like AsyncStorage writes.
1 parent 96fdd13 commit 785502e

File tree

4 files changed

+475
-0
lines changed

4 files changed

+475
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { Hub } from '@aws-amplify/core';
5+
6+
import { ConnectionHealthMonitor } from '../../src/utils/ConnectionHealthMonitor';
7+
8+
jest.mock('@aws-amplify/core');
9+
10+
describe('ConnectionHealthMonitor', () => {
11+
let monitor: ConnectionHealthMonitor;
12+
let hubDispatchSpy: jest.SpyInstance;
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
jest.useFakeTimers();
17+
monitor = new ConnectionHealthMonitor();
18+
hubDispatchSpy = jest.spyOn(Hub, 'dispatch');
19+
});
20+
21+
afterEach(() => {
22+
monitor.reset();
23+
jest.useRealTimers();
24+
});
25+
26+
describe('recordKeepAlive', () => {
27+
it('should update last keep-alive timestamp', () => {
28+
const beforeTime = Date.now();
29+
monitor.recordKeepAlive();
30+
const afterTime = Date.now();
31+
32+
const lastKeepAlive = monitor.getLastKeepAlive();
33+
expect(lastKeepAlive).not.toBeNull();
34+
expect(lastKeepAlive!.getTime()).toBeGreaterThanOrEqual(beforeTime);
35+
expect(lastKeepAlive!.getTime()).toBeLessThanOrEqual(afterTime);
36+
});
37+
38+
it('should increment keepAlivesReceived counter', () => {
39+
monitor.recordKeepAlive();
40+
monitor.recordKeepAlive();
41+
monitor.recordKeepAlive();
42+
43+
const metrics = monitor.getMetrics();
44+
expect(metrics.keepAlivesReceived).toBe(3);
45+
});
46+
47+
it('should reset keepAlivesMissed counter', () => {
48+
monitor.recordKeepAliveMissed();
49+
monitor.recordKeepAliveMissed();
50+
expect(monitor.getMetrics().keepAlivesMissed).toBe(2);
51+
52+
monitor.recordKeepAlive();
53+
expect(monitor.getMetrics().keepAlivesMissed).toBe(0);
54+
});
55+
56+
it('should dispatch Hub event', () => {
57+
monitor.recordKeepAlive();
58+
59+
expect(hubDispatchSpy).toHaveBeenCalledWith('api', {
60+
event: 'WebsocketHealthEvent',
61+
data: expect.objectContaining({
62+
type: 'keepAlive',
63+
timestamp: expect.any(Date),
64+
metrics: expect.objectContaining({
65+
lastKeepAliveAt: expect.any(Date),
66+
keepAlivesReceived: 1,
67+
}),
68+
}),
69+
});
70+
});
71+
72+
it('should notify listeners', () => {
73+
const listener1 = jest.fn();
74+
const listener2 = jest.fn();
75+
76+
monitor.onKeepAlive(listener1);
77+
monitor.onKeepAlive(listener2);
78+
79+
monitor.recordKeepAlive();
80+
81+
expect(listener1).toHaveBeenCalledWith(expect.any(Date));
82+
expect(listener2).toHaveBeenCalledWith(expect.any(Date));
83+
});
84+
});
85+
86+
describe('recordConnectionEstablished', () => {
87+
it('should set connection start time', () => {
88+
monitor.recordConnectionEstablished();
89+
90+
const metrics = monitor.getMetrics();
91+
expect(metrics.connectionStartedAt).not.toBeNull();
92+
});
93+
94+
it('should reset counters', () => {
95+
monitor.recordKeepAlive();
96+
monitor.recordKeepAlive();
97+
monitor.recordKeepAliveMissed();
98+
99+
monitor.recordConnectionEstablished();
100+
101+
const metrics = monitor.getMetrics();
102+
expect(metrics.keepAlivesReceived).toBe(0);
103+
expect(metrics.keepAlivesMissed).toBe(0);
104+
expect(metrics.lastKeepAliveAt).toBeNull();
105+
});
106+
107+
it('should dispatch Hub event', () => {
108+
monitor.recordConnectionEstablished();
109+
110+
expect(hubDispatchSpy).toHaveBeenCalledWith('api', {
111+
event: 'WebsocketHealthEvent',
112+
data: {
113+
type: 'connectionEstablished',
114+
timestamp: expect.any(Date),
115+
},
116+
});
117+
});
118+
});
119+
120+
describe('isHealthy', () => {
121+
it('should return false when no keep-alive received', () => {
122+
expect(monitor.isHealthy()).toBe(false);
123+
});
124+
125+
it('should return true when keep-alive is recent', () => {
126+
monitor.recordKeepAlive();
127+
expect(monitor.isHealthy()).toBe(true);
128+
});
129+
130+
it('should return false when keep-alive is stale', () => {
131+
monitor.recordKeepAlive();
132+
133+
// Advance time past the default 30s threshold
134+
jest.advanceTimersByTime(31000);
135+
136+
expect(monitor.isHealthy()).toBe(false);
137+
});
138+
139+
it('should respect custom threshold', () => {
140+
monitor.recordKeepAlive();
141+
142+
// Advance time to 5 seconds
143+
jest.advanceTimersByTime(5000);
144+
145+
// Should be unhealthy with 3s threshold
146+
expect(monitor.isHealthy(3000)).toBe(false);
147+
148+
// Should be healthy with 10s threshold
149+
expect(monitor.isHealthy(10000)).toBe(true);
150+
});
151+
});
152+
153+
describe('onKeepAlive', () => {
154+
it('should return unsubscribe function', () => {
155+
const listener = jest.fn();
156+
const unsubscribe = monitor.onKeepAlive(listener);
157+
158+
monitor.recordKeepAlive();
159+
expect(listener).toHaveBeenCalledTimes(1);
160+
161+
unsubscribe();
162+
163+
monitor.recordKeepAlive();
164+
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called again
165+
});
166+
});
167+
168+
describe('startHealthCheck', () => {
169+
it('should call onUnhealthy when connection becomes unhealthy', () => {
170+
const onUnhealthy = jest.fn();
171+
172+
monitor.recordKeepAlive();
173+
174+
// Advance time to make connection unhealthy
175+
jest.advanceTimersByTime(31000);
176+
177+
// Start health check after connection is already unhealthy
178+
monitor.startHealthCheck(10000, onUnhealthy);
179+
180+
// Health check hasn't run yet
181+
expect(onUnhealthy).not.toHaveBeenCalled();
182+
183+
// Advance to trigger health check
184+
jest.advanceTimersByTime(10000);
185+
186+
expect(onUnhealthy).toHaveBeenCalledTimes(1);
187+
});
188+
189+
it('should record missed keep-alives', () => {
190+
monitor.recordKeepAlive();
191+
monitor.startHealthCheck(5000);
192+
193+
// Advance past health threshold and trigger check
194+
jest.advanceTimersByTime(36000);
195+
196+
const metrics = monitor.getMetrics();
197+
expect(metrics.keepAlivesMissed).toBe(1);
198+
});
199+
200+
it('should stop previous health check when starting new one', () => {
201+
const onUnhealthy1 = jest.fn();
202+
const onUnhealthy2 = jest.fn();
203+
204+
monitor.recordKeepAlive();
205+
206+
// Start first health check with 5s interval
207+
monitor.startHealthCheck(5000, onUnhealthy1);
208+
209+
// Advance 3 seconds (not enough to trigger first check)
210+
jest.advanceTimersByTime(3000);
211+
212+
// Start second health check (cancels first)
213+
monitor.startHealthCheck(15000, onUnhealthy2);
214+
215+
// Advance past first check interval
216+
jest.advanceTimersByTime(5000);
217+
218+
// First callback should not be called (was cancelled)
219+
expect(onUnhealthy1).not.toHaveBeenCalled();
220+
221+
// Make connection unhealthy
222+
jest.advanceTimersByTime(23000); // Total 31s since keep-alive
223+
224+
// Advance to trigger second health check (15s interval)
225+
jest.advanceTimersByTime(7000); // Total 15s since second check started
226+
227+
// Second callback should be called once
228+
expect(onUnhealthy2).toHaveBeenCalledTimes(1);
229+
});
230+
});
231+
232+
describe('getMetrics', () => {
233+
it('should return comprehensive metrics', () => {
234+
monitor.recordConnectionEstablished();
235+
monitor.recordKeepAlive();
236+
monitor.recordKeepAlive();
237+
monitor.recordKeepAliveMissed();
238+
239+
const metrics = monitor.getMetrics();
240+
241+
expect(metrics).toEqual({
242+
lastKeepAliveAt: expect.any(Date),
243+
connectionStartedAt: expect.any(Date),
244+
keepAlivesReceived: 2,
245+
keepAlivesMissed: 1,
246+
isHealthy: true,
247+
});
248+
});
249+
});
250+
251+
describe('reset', () => {
252+
it('should clear all state', () => {
253+
const listener = jest.fn();
254+
const onUnhealthy = jest.fn();
255+
256+
monitor.recordConnectionEstablished();
257+
monitor.recordKeepAlive();
258+
monitor.recordKeepAliveMissed();
259+
monitor.onKeepAlive(listener);
260+
monitor.startHealthCheck(5000, onUnhealthy);
261+
262+
monitor.reset();
263+
264+
const metrics = monitor.getMetrics();
265+
expect(metrics).toEqual({
266+
lastKeepAliveAt: null,
267+
connectionStartedAt: null,
268+
keepAlivesReceived: 0,
269+
keepAlivesMissed: 0,
270+
isHealthy: false,
271+
});
272+
273+
// Verify health check stopped
274+
jest.advanceTimersByTime(40000);
275+
expect(onUnhealthy).not.toHaveBeenCalled();
276+
277+
// Verify listeners cleared
278+
monitor.recordKeepAlive();
279+
expect(listener).not.toHaveBeenCalled();
280+
});
281+
});
282+
});

packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
ReconnectEvent,
4141
ReconnectionMonitor,
4242
} from '../../utils/ReconnectionMonitor';
43+
import { ConnectionHealthMonitor } from '../../utils/ConnectionHealthMonitor';
4344
import type { AWSAppSyncRealTimeProviderOptions } from '../AWSAppSyncRealTimeProvider';
4445

4546
import {
@@ -88,6 +89,7 @@ export abstract class AWSWebSocketProvider {
8889
private keepAliveHeartbeatIntervalId?: ReturnType<typeof setInterval>;
8990
private promiseArray: { res(): void; rej(reason?: any): void }[] = [];
9091
private connectionState: ConnectionState | undefined;
92+
private readonly connectionHealthMonitor = new ConnectionHealthMonitor();
9193
private readonly connectionStateMonitor = new ConnectionStateMonitor();
9294
private readonly reconnectionMonitor = new ReconnectionMonitor();
9395
private connectionStateMonitorSubscription: SubscriptionLike;
@@ -106,6 +108,13 @@ export abstract class AWSWebSocketProvider {
106108
/**
107109
* Mark the socket closed and release all active listeners
108110
*/
111+
/**
112+
* Get the connection health monitor for external health checks
113+
*/
114+
getConnectionHealthMonitor() {
115+
return this.connectionHealthMonitor;
116+
}
117+
109118
close() {
110119
// Mark the socket closed both in status and the connection monitor
111120
this.socketStatus = SOCKET_STATUS.CLOSED;
@@ -115,6 +124,8 @@ export abstract class AWSWebSocketProvider {
115124
this.connectionStateMonitorSubscription.unsubscribe();
116125
// Complete all reconnect observers
117126
this.reconnectionMonitor.close();
127+
// Reset health monitor
128+
this.connectionHealthMonitor.reset();
118129

119130
return new Promise<void>((resolve, reject) => {
120131
if (this.awsRealTimeSocket) {
@@ -599,6 +610,7 @@ export abstract class AWSWebSocketProvider {
599610

600611
private maintainKeepAlive() {
601612
this.keepAliveTimestamp = Date.now();
613+
this.connectionHealthMonitor.recordKeepAlive();
602614
}
603615

604616
private keepAliveHeartbeat(connectionTimeoutMs: number) {
@@ -927,6 +939,7 @@ export abstract class AWSWebSocketProvider {
927939

928940
if (type === MESSAGE_TYPES.GQL_CONNECTION_ACK) {
929941
ackOk = true;
942+
this.connectionHealthMonitor.recordConnectionEstablished();
930943
this._registerWebsocketHandlers(connectionTimeoutMs);
931944
resolve('Connected to AWS AppSyncRealTime');
932945

0 commit comments

Comments
 (0)