فهرست منبع

[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 <alf.eaton@overleaf.com>

* 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 <alf.eaton@overleaf.com>
GitOrigin-RevId: fbebe64f8aa207eb4fd4a8f27d522d1cac35f9d4
Jakob Ackermann 1 سال پیش
والد
کامیت
45a5d090d9

+ 1 - 0
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',

+ 66 - 4
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
+  }
 }

+ 15 - 0
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
+}

+ 12 - 1
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,
     ]
   )
 

+ 5 - 4
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<string, any>) => {
@@ -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<string, any> = {}
@@ -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

+ 26 - 11
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 (
     <ConnectionContext.Provider value={value}>