From 15ca1d003a74af82769944b8037366dd80e75ed2 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 27 Jan 2025 13:46:49 +0000 Subject: [PATCH] [web] add external 15s heartbeat to socket.io connection (#22853) * [web] add external 15s heartbeat to socket.io connection * [web] extend debugging context for client errors - include performance.now() timestamp for correlating other timings - include connectionState, especially for new externalHeartbeat detail - include spellCheckLanguage to check on client-side spelling impact * [web] remove unnecessary hook dependency Co-authored-by: Alf Eaton * Refactor externalHeartbeat * Add connectionManager to context * Clear the interval earlier, and on connect * [web] refactor handling of socket debugging info * [web] add split-test for external socket heartbeat * [web] fully remove connectionManager from connection context --------- Co-authored-by: Alf Eaton GitOrigin-RevId: fbebe64f8aa207eb4fd4a8f27d522d1cac35f9d4 --- .../src/Features/Project/ProjectController.js | 1 + .../connection/connection-manager.ts | 70 +++++++++++++++++-- .../connection/types/connection-state.ts | 15 ++++ .../ide-react/context/connection-context.tsx | 13 +++- .../ide-react/context/ide-react-context.tsx | 9 +-- .../web/frontend/stories/decorators/scope.tsx | 37 +++++++--- 6 files changed, 125 insertions(+), 20 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 7bf8db6cc8..e69d5d7a66 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -333,6 +333,7 @@ const _ProjectController = { const splitTests = [ !anonymous && 'bib-file-tpr-prompt', 'compile-log-events', + 'external-socket-heartbeat', 'full-project-search', 'math-preview', 'null-test-share-modal', diff --git a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts index a9990d2558..66f9d8762b 100644 --- a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts +++ b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts @@ -1,8 +1,14 @@ -import { ConnectionError, ConnectionState } from './types/connection-state' +import { + ConnectionError, + ConnectionState, + ExternalHeartbeat, + SocketDebuggingInfo, +} from './types/connection-state' import SocketIoShim from '../../../ide/connection/SocketIoShim' import getMeta from '../../../utils/meta' import { Socket } from '@/features/ide-react/connection/types/socket' import { debugConsole } from '@/utils/debugging' +import { isSplitTestEnabled } from '@/utils/splitTestUtils' const ONE_HOUR_IN_MS = 1000 * 60 * 60 const TWO_MINUTES_IN_MS = 2 * 60 * 1000 @@ -18,6 +24,8 @@ const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = 45 * 1000 const MAX_RETRY_CONNECT = 5 +const externalSocketHeartbeat = isSplitTestEnabled('external-socket-heartbeat') + const initialState: ConnectionState = { readyState: WebSocket.CLOSED, forceDisconnected: false, @@ -43,6 +51,12 @@ export class ConnectionManager extends EventTarget { private reconnectCountdownInterval = 0 readonly socket: Socket private userIsLeavingPage = false + private externalHeartbeatInterval?: number + private externalHeartbeat: ExternalHeartbeat = { + currentStart: 0, + lastSuccess: 0, + lastLatency: 0, + } constructor() { super() @@ -61,14 +75,18 @@ export class ConnectionManager extends EventTarget { getMeta('ol-wsUrl') || '/socket.io', window.origin ) + const query = new URLSearchParams({ + projectId: getMeta('ol-project_id'), + }) + if (externalSocketHeartbeat) { + query.set('esh', '1') + } const socket = SocketIoShim.connect(parsedURL.origin, { resource: parsedURL.pathname.slice(1), 'auto connect': false, 'connect timeout': 30 * 1000, 'force new connection': true, - query: new URLSearchParams({ - projectId: getMeta('ol-project_id'), - }).toString(), + query: query.toString(), reconnect: false, }) as unknown as Socket this.socket = socket @@ -86,6 +104,7 @@ export class ConnectionManager extends EventTarget { return } + socket.on('connect', () => this.onConnect()) socket.on('disconnect', () => this.onDisconnect()) socket.on('error', () => this.onConnectError()) socket.on('connect_failed', () => this.onConnectError()) @@ -112,6 +131,17 @@ export class ConnectionManager extends EventTarget { this.ensureIsConnected() } + getSocketDebuggingInfo(): SocketDebuggingInfo { + return { + client_id: this.socket.socket?.sessionid, + transport: this.socket.socket?.transport?.name, + publicId: this.socket.publicId, + lastUserActivity: this.lastUserActivity, + connectionState: this.state, + externalHeartbeat: this.externalHeartbeat, + } + } + private changeState(state: ConnectionState) { const previousState = this.state this.state = state @@ -198,8 +228,24 @@ export class ConnectionManager extends EventTarget { } } + private onConnect() { + if (externalSocketHeartbeat) { + if (this.externalHeartbeatInterval) { + window.clearInterval(this.externalHeartbeatInterval) + } + this.externalHeartbeatInterval = window.setInterval( + () => this.sendExternalHeartbeat(), + 15_000 + ) + } + } + private onDisconnect() { this.connectionAttempt = null + if (this.externalHeartbeatInterval) { + window.clearInterval(this.externalHeartbeatInterval) + } + this.externalHeartbeat.currentStart = 0 this.changeState({ ...this.state, readyState: WebSocket.CLOSED, @@ -401,4 +447,20 @@ export class ConnectionManager extends EventTarget { this.tryReconnect() } } + + private sendExternalHeartbeat() { + const t0 = performance.now() + this.socket.emit('debug.getHostname', () => { + if (this.externalHeartbeat.currentStart !== t0) { + return + } + const t1 = performance.now() + this.externalHeartbeat = { + currentStart: 0, + lastSuccess: t1, + lastLatency: t1 - t0, + } + }) + this.externalHeartbeat.currentStart = t0 + } } diff --git a/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts b/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts index e95bd13442..fc445c7d9d 100644 --- a/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts +++ b/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts @@ -18,3 +18,18 @@ export type ConnectionState = { lastConnectionAttempt: number error: '' | ConnectionError } + +export type ExternalHeartbeat = { + currentStart: number + lastSuccess: number + lastLatency: number +} + +export type SocketDebuggingInfo = { + client_id?: string + publicId?: string + transport?: string + lastUserActivity: number + connectionState: ConnectionState + externalHeartbeat: ExternalHeartbeat +} diff --git a/services/web/frontend/js/features/ide-react/context/connection-context.tsx b/services/web/frontend/js/features/ide-react/context/connection-context.tsx index 5213abf0d1..3ab5dc4a3e 100644 --- a/services/web/frontend/js/features/ide-react/context/connection-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/connection-context.tsx @@ -7,7 +7,10 @@ import { useCallback, useMemo, } from 'react' -import { ConnectionState } from '../connection/types/connection-state' +import { + ConnectionState, + SocketDebuggingInfo, +} from '../connection/types/connection-state' import { ConnectionManager, StateChangeEvent, @@ -25,6 +28,7 @@ type ConnectionContextValue = { tryReconnectNow: () => void registerUserActivity: () => void disconnect: () => void + getSocketDebuggingInfo: () => SocketDebuggingInfo } export const ConnectionContext = createContext< @@ -75,6 +79,11 @@ export const ConnectionProvider: FC = ({ children }) => { connectionManager.disconnect() }, [connectionManager]) + const getSocketDebuggingInfo = useCallback( + () => connectionManager.getSocketDebuggingInfo(), + [connectionManager] + ) + // Reload the page on force disconnect. Doing this in React-land means that we // can use useLocation(), which provides mockable location methods useEffect(() => { @@ -103,6 +112,7 @@ export const ConnectionProvider: FC = ({ children }) => { tryReconnectNow, registerUserActivity, disconnect, + getSocketDebuggingInfo, }), [ connectionManager.socket, @@ -113,6 +123,7 @@ export const ConnectionProvider: FC = ({ children }) => { secondsUntilReconnect, tryReconnectNow, disconnect, + getSocketDebuggingInfo, ] ) diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index d99912fb52..5f2ede44ae 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx @@ -100,7 +100,7 @@ export const IdeReactProvider: FC = ({ children }) => { // been called const [projectJoined, setProjectJoined] = useState(false) - const { socket } = useConnectionContext() + const { socket, getSocketDebuggingInfo } = useConnectionContext() const reportError = useCallback( (error: any, meta?: Record) => { @@ -108,11 +108,12 @@ export const IdeReactProvider: FC = ({ children }) => { ...meta, user_id: getMeta('ol-user_id'), project_id: projectId, - client_id: socket.socket?.sessionid, - transport: socket.socket?.transport?.name, client_now: new Date(), + performance_now: performance.now(), release, client_load: LOADED_AT, + spellCheckLanguage: scopeStore.get('project.spellCheckLanguage'), + ...getSocketDebuggingInfo(), } const errorObj: Record = {} @@ -130,7 +131,7 @@ export const IdeReactProvider: FC = ({ children }) => { }, }) }, - [socket.socket, release, projectId] + [release, projectId, getSocketDebuggingInfo, scopeStore] ) // Populate scope values when joining project, then fire project:joined event diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index 535ed32e4e..4986cbea24 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -166,9 +166,8 @@ export const ScopeDecorator = ( } const ConnectionProvider: FC = ({ children }) => { - const [value] = useState(() => ({ - socket: window._ide.socket as Socket, - connectionState: { + const [value] = useState(() => { + const connectionState: ConnectionState = { readyState: WebSocket.OPEN, forceDisconnected: false, inactiveDisconnect: false, @@ -176,14 +175,30 @@ const ConnectionProvider: FC = ({ children }) => { forcedDisconnectDelay: 0, lastConnectionAttempt: 0, error: '', - } as ConnectionState, - isConnected: true, - isStillReconnecting: false, - secondsUntilReconnect: () => 0, - tryReconnectNow: () => {}, - registerUserActivity: () => {}, - disconnect: () => {}, - })) + } + return { + socket: window._ide.socket as Socket, + connectionState, + isConnected: true, + isStillReconnecting: false, + secondsUntilReconnect: () => 0, + tryReconnectNow: () => {}, + registerUserActivity: () => {}, + disconnect: () => {}, + getSocketDebuggingInfo: () => ({ + client_id: 'fakeClientId', + transport: 'fakeTransport', + publicId: 'fakePublicId', + lastUserActivity: 0, + connectionState, + externalHeartbeat: { + currentStart: 0, + lastSuccess: 0, + lastLatency: 0, + }, + }), + } + }) return (