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.
uri
host
port
protocol
cert_file
key_file
max_queue_size
client_map
client_map_lock
send_queue
loop
server_run_thread
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