openrvdas.logger.writers.websocket_writer
No module-level documentation available.
1#!/usr/bin/env python3 2""" 3""" 4import asyncio 5import logging 6import ssl 7import threading 8import time 9import inspect 10 11from typing import Union 12from urllib.parse import urlparse 13 14 15from logger.writers.writer import Writer # noqa E402 16 17try: 18 import websockets 19 20 WEBSOCKETS_INSTALLED = True 21except ImportError: 22 WEBSOCKETS_INSTALLED = False 23 24 25################################################################################ 26class WebsocketWriter(Writer): 27 ############################ 28 29 def __init__(self, uri, cert_file=None, key_file=None, max_queue_size=None, **kwargs): 30 """ 31 ``` 32 uri Protocol, hostname and port to serve as. E.g. 'wss://openrvdas:8081' 33 If protocol is 'wss', use SSL, and cert_file and key_file must be 34 specified. 35 36 cert_file If using ssl, the file path to relevant certificate and key files. 37 key_file 38 39 max_queue_size If specified, the maximum number of records to hold in each client's 40 send queue. When the queue exceeds this size, the oldest records are 41 discarded and a warning is logged indicating how many records were 42 dropped and how old the oldest dropped record was. Useful when 43 consumers only care about the most recent data (e.g. live dashboards) 44 and you want to avoid unbounded queue growth during network hiccups. 45 ``` 46 """ 47 # Initialize type checking 48 super().__init__(**kwargs) # processes 'quiet' and type hints 49 50 if not WEBSOCKETS_INSTALLED: 51 raise ImportError('WebsocketWriter requires Python "websockets" module; ' 52 'please run "pip install websockets"') 53 self.uri = uri 54 parsed_uri = urlparse(uri) 55 self.host = parsed_uri.hostname 56 self.port = parsed_uri.port 57 self.protocol = parsed_uri.scheme 58 59 if self.protocol == 'wss': 60 self.ssl = True 61 if (not cert_file or not key_file): 62 raise ValueError('Both cert_file and key_file must be specified for wss') 63 elif self.protocol == 'ws': 64 self.ssl = False 65 if (cert_file or key_file): 66 raise ValueError('If protocol is ws, cert_file and key_file should be empty') 67 else: 68 raise ValueError(f'Protocol "{self.protocol}" not recognized. Must be ws or wss') 69 70 self.cert_file = cert_file 71 self.key_file = key_file 72 self.max_queue_size = max_queue_size 73 74 # Map from client_id to websocket 75 self.client_map = {} 76 self.client_map_lock = threading.Lock() 77 78 # Send queue for each client 79 self.send_queue = {} 80 81 # Event loop - we'll set this in run() 82 self.loop = None 83 84 # Start async websocket server in a separate thread 85 self.server_run_thread = threading.Thread(target=self.run) 86 self.server_run_thread.start() 87 88 ############################ 89 def _put_and_trim(self, client_id, record): 90 """Enqueue a record (with timestamp) and drop oldest items if over max_queue_size. 91 92 Runs inside the event loop (called via call_soon_threadsafe), so asyncio 93 Queue operations are safe here. 94 """ 95 queue = self.send_queue.get(client_id) 96 if queue is None: 97 return # client disconnected before this callback ran 98 99 queue.put_nowait((time.time(), record)) 100 101 if self.max_queue_size and queue.qsize() > self.max_queue_size: 102 n_to_drop = queue.qsize() - self.max_queue_size 103 oldest_time = None 104 for _ in range(n_to_drop): 105 try: 106 ts, _ = queue.get_nowait() 107 if oldest_time is None: 108 oldest_time = ts 109 except asyncio.QueueEmpty: 110 break 111 age_str = (f', oldest was {time.time() - oldest_time:.1f}s old' 112 if oldest_time is not None else '') 113 logging.warning( 114 f'WebsocketWriter: dropped {n_to_drop} queued record(s) for client ' 115 f'{client_id}{age_str} (max_queue_size={self.max_queue_size})') 116 117 ############################ 118 async def _send_from_queue(self, client_id): 119 """ 120 Asynchronously wait for stuff to show up in client's queue, pop it off 121 and send to client's websocket. Queue items are (enqueue_time, record) tuples. 122 """ 123 try: 124 # The websocket, send_queue and send_queue_lock for this client 125 websocket = self.client_map[client_id] 126 send_queue = self.send_queue[client_id] 127 while True: 128 _enqueue_time, record = await send_queue.get() 129 await websocket.send(record) 130 logging.debug(f'WebsocketWriter sent client {client_id} record: {record}') 131 132 except websockets.exceptions.ConnectionClosed: # type: ignore 133 logging.info(f'Websocket connection lost for client {client_id}') 134 135 ############################ 136 # FIX: path=None makes this compatible with both new (1 arg) and old (2 args) libs 137 async def _websocket_handler(self, websocket, path=None): 138 with self.client_map_lock: 139 # Find a unique client_id for this client 140 client_id = 0 141 while client_id in self.client_map: 142 client_id += 1 143 144 logging.info(f'New client #{client_id} attached') 145 self.client_map[client_id] = websocket 146 self.send_queue[client_id] = asyncio.Queue() 147 148 # Task that produces data that we're going to send out on websocket 149 tasks = [asyncio.ensure_future(self._send_from_queue(client_id))] 150 await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 151 152 # When client disconnects, delete the websocket and queue it was using 153 with self.client_map_lock: 154 logging.info(f'WebsocketServer client #{client_id} completed') 155 del self.client_map[client_id] 156 del self.send_queue[client_id] 157 158 ############################ 159 def run(self): 160 # Create an SSL context if we're using SSL 161 ssl_context = None 162 if self.ssl: 163 ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 164 ssl_context.load_cert_chain( 165 certfile=self.cert_file, keyfile=self.key_file) # type: ignore 166 167 # Set up an event loop for the websocket server 168 self.loop = asyncio.new_event_loop() 169 asyncio.set_event_loop(self.loop) 170 171 # Build arguments dynamically to support both old and new websockets versions 172 serve_kwargs = { 173 'host': self.host, 174 'port': self.port, 175 'ssl': ssl_context 176 } 177 178 # Check signature of websockets.serve to decide on 'handler' vs 'ws_handler' 179 sig = inspect.signature(websockets.serve) 180 if 'handler' in sig.parameters: 181 serve_kwargs['handler'] = self._websocket_handler 182 else: 183 serve_kwargs['ws_handler'] = self._websocket_handler 184 185 async def runner(): 186 # Create the server object/context manager 187 # We must do this INSIDE the coroutine so that 'websockets' 188 # can find the running loop. 189 server_result = websockets.serve(**serve_kwargs) 190 191 # Modern websockets (v10+): returns an AsyncContextManager 192 if hasattr(server_result, '__aenter__'): 193 async with server_result: 194 # Keep the loop running while the server is active 195 await asyncio.Future() 196 197 # Legacy websockets: returns an awaitable that yields the server 198 else: 199 await server_result 200 # Keep the loop running 201 await asyncio.Future() 202 203 try: 204 # run_until_complete starts the loop, then executes runner(), 205 # which keeps running via await asyncio.Future() 206 self.loop.run_until_complete(runner()) 207 except Exception as e: 208 logging.error(f"WebsocketWriter server loop error: {e}") 209 210 ############################ 211 def write(self, record: Union[str, bytes]): 212 """Write a record to all connected clients""" 213 214 # See if it's something we can process, and if not, try digesting 215 if not self.can_process_record(record): # inherited from BaseModule() 216 self.digest_record(record) # inherited from BaseModule() 217 return 218 219 logging.debug(f'WebsocketWriter received record: {record}') 220 with self.client_map_lock: 221 for client_id in self.client_map: 222 logging.debug(f'Pushing record to client {client_id}') 223 self.loop.call_soon_threadsafe( # type: ignore 224 self._put_and_trim, client_id, record)
class
WebsocketWriter(logger.writers.writer.Writer):
27class WebsocketWriter(Writer): 28 ############################ 29 30 def __init__(self, uri, cert_file=None, key_file=None, max_queue_size=None, **kwargs): 31 """ 32 ``` 33 uri Protocol, hostname and port to serve as. E.g. 'wss://openrvdas:8081' 34 If protocol is 'wss', use SSL, and cert_file and key_file must be 35 specified. 36 37 cert_file If using ssl, the file path to relevant certificate and key files. 38 key_file 39 40 max_queue_size If specified, the maximum number of records to hold in each client's 41 send queue. When the queue exceeds this size, the oldest records are 42 discarded and a warning is logged indicating how many records were 43 dropped and how old the oldest dropped record was. Useful when 44 consumers only care about the most recent data (e.g. live dashboards) 45 and you want to avoid unbounded queue growth during network hiccups. 46 ``` 47 """ 48 # Initialize type checking 49 super().__init__(**kwargs) # processes 'quiet' and type hints 50 51 if not WEBSOCKETS_INSTALLED: 52 raise ImportError('WebsocketWriter requires Python "websockets" module; ' 53 'please run "pip install websockets"') 54 self.uri = uri 55 parsed_uri = urlparse(uri) 56 self.host = parsed_uri.hostname 57 self.port = parsed_uri.port 58 self.protocol = parsed_uri.scheme 59 60 if self.protocol == 'wss': 61 self.ssl = True 62 if (not cert_file or not key_file): 63 raise ValueError('Both cert_file and key_file must be specified for wss') 64 elif self.protocol == 'ws': 65 self.ssl = False 66 if (cert_file or key_file): 67 raise ValueError('If protocol is ws, cert_file and key_file should be empty') 68 else: 69 raise ValueError(f'Protocol "{self.protocol}" not recognized. Must be ws or wss') 70 71 self.cert_file = cert_file 72 self.key_file = key_file 73 self.max_queue_size = max_queue_size 74 75 # Map from client_id to websocket 76 self.client_map = {} 77 self.client_map_lock = threading.Lock() 78 79 # Send queue for each client 80 self.send_queue = {} 81 82 # Event loop - we'll set this in run() 83 self.loop = None 84 85 # Start async websocket server in a separate thread 86 self.server_run_thread = threading.Thread(target=self.run) 87 self.server_run_thread.start() 88 89 ############################ 90 def _put_and_trim(self, client_id, record): 91 """Enqueue a record (with timestamp) and drop oldest items if over max_queue_size. 92 93 Runs inside the event loop (called via call_soon_threadsafe), so asyncio 94 Queue operations are safe here. 95 """ 96 queue = self.send_queue.get(client_id) 97 if queue is None: 98 return # client disconnected before this callback ran 99 100 queue.put_nowait((time.time(), record)) 101 102 if self.max_queue_size and queue.qsize() > self.max_queue_size: 103 n_to_drop = queue.qsize() - self.max_queue_size 104 oldest_time = None 105 for _ in range(n_to_drop): 106 try: 107 ts, _ = queue.get_nowait() 108 if oldest_time is None: 109 oldest_time = ts 110 except asyncio.QueueEmpty: 111 break 112 age_str = (f', oldest was {time.time() - oldest_time:.1f}s old' 113 if oldest_time is not None else '') 114 logging.warning( 115 f'WebsocketWriter: dropped {n_to_drop} queued record(s) for client ' 116 f'{client_id}{age_str} (max_queue_size={self.max_queue_size})') 117 118 ############################ 119 async def _send_from_queue(self, client_id): 120 """ 121 Asynchronously wait for stuff to show up in client's queue, pop it off 122 and send to client's websocket. Queue items are (enqueue_time, record) tuples. 123 """ 124 try: 125 # The websocket, send_queue and send_queue_lock for this client 126 websocket = self.client_map[client_id] 127 send_queue = self.send_queue[client_id] 128 while True: 129 _enqueue_time, record = await send_queue.get() 130 await websocket.send(record) 131 logging.debug(f'WebsocketWriter sent client {client_id} record: {record}') 132 133 except websockets.exceptions.ConnectionClosed: # type: ignore 134 logging.info(f'Websocket connection lost for client {client_id}') 135 136 ############################ 137 # FIX: path=None makes this compatible with both new (1 arg) and old (2 args) libs 138 async def _websocket_handler(self, websocket, path=None): 139 with self.client_map_lock: 140 # Find a unique client_id for this client 141 client_id = 0 142 while client_id in self.client_map: 143 client_id += 1 144 145 logging.info(f'New client #{client_id} attached') 146 self.client_map[client_id] = websocket 147 self.send_queue[client_id] = asyncio.Queue() 148 149 # Task that produces data that we're going to send out on websocket 150 tasks = [asyncio.ensure_future(self._send_from_queue(client_id))] 151 await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) 152 153 # When client disconnects, delete the websocket and queue it was using 154 with self.client_map_lock: 155 logging.info(f'WebsocketServer client #{client_id} completed') 156 del self.client_map[client_id] 157 del self.send_queue[client_id] 158 159 ############################ 160 def run(self): 161 # Create an SSL context if we're using SSL 162 ssl_context = None 163 if self.ssl: 164 ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 165 ssl_context.load_cert_chain( 166 certfile=self.cert_file, keyfile=self.key_file) # type: ignore 167 168 # Set up an event loop for the websocket server 169 self.loop = asyncio.new_event_loop() 170 asyncio.set_event_loop(self.loop) 171 172 # Build arguments dynamically to support both old and new websockets versions 173 serve_kwargs = { 174 'host': self.host, 175 'port': self.port, 176 'ssl': ssl_context 177 } 178 179 # Check signature of websockets.serve to decide on 'handler' vs 'ws_handler' 180 sig = inspect.signature(websockets.serve) 181 if 'handler' in sig.parameters: 182 serve_kwargs['handler'] = self._websocket_handler 183 else: 184 serve_kwargs['ws_handler'] = self._websocket_handler 185 186 async def runner(): 187 # Create the server object/context manager 188 # We must do this INSIDE the coroutine so that 'websockets' 189 # can find the running loop. 190 server_result = websockets.serve(**serve_kwargs) 191 192 # Modern websockets (v10+): returns an AsyncContextManager 193 if hasattr(server_result, '__aenter__'): 194 async with server_result: 195 # Keep the loop running while the server is active 196 await asyncio.Future() 197 198 # Legacy websockets: returns an awaitable that yields the server 199 else: 200 await server_result 201 # Keep the loop running 202 await asyncio.Future() 203 204 try: 205 # run_until_complete starts the loop, then executes runner(), 206 # which keeps running via await asyncio.Future() 207 self.loop.run_until_complete(runner()) 208 except Exception as e: 209 logging.error(f"WebsocketWriter server loop error: {e}") 210 211 ############################ 212 def write(self, record: Union[str, bytes]): 213 """Write a record to all connected clients""" 214 215 # See if it's something we can process, and if not, try digesting 216 if not self.can_process_record(record): # inherited from BaseModule() 217 self.digest_record(record) # inherited from BaseModule() 218 return 219 220 logging.debug(f'WebsocketWriter received record: {record}') 221 with self.client_map_lock: 222 for client_id in self.client_map: 223 logging.debug(f'Pushing record to client {client_id}') 224 self.loop.call_soon_threadsafe( # type: ignore 225 self._put_and_trim, client_id, record)
Base class Writer about which we know nothing else. By default the input format is Unknown unless overridden.
Passes arguments quiet, encoding and encoding_errors up to BaseModule
WebsocketWriter(uri, cert_file=None, key_file=None, max_queue_size=None, **kwargs)
30 def __init__(self, uri, cert_file=None, key_file=None, max_queue_size=None, **kwargs): 31 """ 32 ``` 33 uri Protocol, hostname and port to serve as. E.g. 'wss://openrvdas:8081' 34 If protocol is 'wss', use SSL, and cert_file and key_file must be 35 specified. 36 37 cert_file If using ssl, the file path to relevant certificate and key files. 38 key_file 39 40 max_queue_size If specified, the maximum number of records to hold in each client's 41 send queue. When the queue exceeds this size, the oldest records are 42 discarded and a warning is logged indicating how many records were 43 dropped and how old the oldest dropped record was. Useful when 44 consumers only care about the most recent data (e.g. live dashboards) 45 and you want to avoid unbounded queue growth during network hiccups. 46 ``` 47 """ 48 # Initialize type checking 49 super().__init__(**kwargs) # processes 'quiet' and type hints 50 51 if not WEBSOCKETS_INSTALLED: 52 raise ImportError('WebsocketWriter requires Python "websockets" module; ' 53 'please run "pip install websockets"') 54 self.uri = uri 55 parsed_uri = urlparse(uri) 56 self.host = parsed_uri.hostname 57 self.port = parsed_uri.port 58 self.protocol = parsed_uri.scheme 59 60 if self.protocol == 'wss': 61 self.ssl = True 62 if (not cert_file or not key_file): 63 raise ValueError('Both cert_file and key_file must be specified for wss') 64 elif self.protocol == 'ws': 65 self.ssl = False 66 if (cert_file or key_file): 67 raise ValueError('If protocol is ws, cert_file and key_file should be empty') 68 else: 69 raise ValueError(f'Protocol "{self.protocol}" not recognized. Must be ws or wss') 70 71 self.cert_file = cert_file 72 self.key_file = key_file 73 self.max_queue_size = max_queue_size 74 75 # Map from client_id to websocket 76 self.client_map = {} 77 self.client_map_lock = threading.Lock() 78 79 # Send queue for each client 80 self.send_queue = {} 81 82 # Event loop - we'll set this in run() 83 self.loop = None 84 85 # Start async websocket server in a separate thread 86 self.server_run_thread = threading.Thread(target=self.run) 87 self.server_run_thread.start()
uri Protocol, hostname and port to serve as. E.g. 'wss://openrvdas:8081'
If protocol is 'wss', use SSL, and cert_file and key_file must be
specified.
cert_file If using ssl, the file path to relevant certificate and key files.
key_file
max_queue_size If specified, the maximum number of records to hold in each client's
send queue. When the queue exceeds this size, the oldest records are
discarded and a warning is logged indicating how many records were
dropped and how old the oldest dropped record was. Useful when
consumers only care about the most recent data (e.g. live dashboards)
and you want to avoid unbounded queue growth during network hiccups.
def
run(self):
160 def run(self): 161 # Create an SSL context if we're using SSL 162 ssl_context = None 163 if self.ssl: 164 ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 165 ssl_context.load_cert_chain( 166 certfile=self.cert_file, keyfile=self.key_file) # type: ignore 167 168 # Set up an event loop for the websocket server 169 self.loop = asyncio.new_event_loop() 170 asyncio.set_event_loop(self.loop) 171 172 # Build arguments dynamically to support both old and new websockets versions 173 serve_kwargs = { 174 'host': self.host, 175 'port': self.port, 176 'ssl': ssl_context 177 } 178 179 # Check signature of websockets.serve to decide on 'handler' vs 'ws_handler' 180 sig = inspect.signature(websockets.serve) 181 if 'handler' in sig.parameters: 182 serve_kwargs['handler'] = self._websocket_handler 183 else: 184 serve_kwargs['ws_handler'] = self._websocket_handler 185 186 async def runner(): 187 # Create the server object/context manager 188 # We must do this INSIDE the coroutine so that 'websockets' 189 # can find the running loop. 190 server_result = websockets.serve(**serve_kwargs) 191 192 # Modern websockets (v10+): returns an AsyncContextManager 193 if hasattr(server_result, '__aenter__'): 194 async with server_result: 195 # Keep the loop running while the server is active 196 await asyncio.Future() 197 198 # Legacy websockets: returns an awaitable that yields the server 199 else: 200 await server_result 201 # Keep the loop running 202 await asyncio.Future() 203 204 try: 205 # run_until_complete starts the loop, then executes runner(), 206 # which keeps running via await asyncio.Future() 207 self.loop.run_until_complete(runner()) 208 except Exception as e: 209 logging.error(f"WebsocketWriter server loop error: {e}")
def
write(self, record: Union[str, bytes]):
212 def write(self, record: Union[str, bytes]): 213 """Write a record to all connected clients""" 214 215 # See if it's something we can process, and if not, try digesting 216 if not self.can_process_record(record): # inherited from BaseModule() 217 self.digest_record(record) # inherited from BaseModule() 218 return 219 220 logging.debug(f'WebsocketWriter received record: {record}') 221 with self.client_map_lock: 222 for client_id in self.client_map: 223 logging.debug(f'Pushing record to client {client_id}') 224 self.loop.call_soon_threadsafe( # type: ignore 225 self._put_and_trim, client_id, record)
Write a record to all connected clients