openrvdas.logger.writers.grafana_live_writer

This writer pushes data to Grafana Live using the /api/live/push endpoint with InfluxDB Line Protocol format. This is a supported, stable API.

NOTE: This code was created substantially via Google Gemini. The usual AI code disclaimers apply - watch your topknot...

SECURITY - AUTHENTICATION TOKEN: The Grafana Service Account Token can be provided in three ways, checked in the following order of priority:

  1. token_file (BEST): Path to a file containing the token (e.g., /etc/secrets/grafana_token). Set file permissions to 600 so only the process owner can read it.

  2. GRAFANA_API_TOKEN (GOOD): Environment variable. Better than hardcoding, but visible to root.

  3. api_token (LEAST SECURE): Passed directly as a string argument. Discouraged for production use as it may appear in version control or process lists.

  1#!/usr/bin/env python3
  2"""
  3This writer pushes data to Grafana Live using the /api/live/push endpoint
  4with InfluxDB Line Protocol format. This is a supported, stable API.
  5
  6NOTE: This code was created substantially via Google Gemini. The usual
  7AI code disclaimers apply - watch your topknot...
  8
  9SECURITY - AUTHENTICATION TOKEN:
 10The Grafana Service Account Token can be provided in three ways, checked in
 11the following order of priority:
 12
 131. token_file (BEST):
 14   Path to a file containing the token (e.g., /etc/secrets/grafana_token).
 15   Set file permissions to 600 so only the process owner can read it.
 16
 172. GRAFANA_API_TOKEN (GOOD):
 18   Environment variable. Better than hardcoding, but visible to root.
 19
 203. api_token (LEAST SECURE):
 21   Passed directly as a string argument. Discouraged for production use
 22   as it may appear in version control or process lists.
 23"""
 24
 25import logging
 26import json
 27import time
 28import threading
 29import queue
 30import urllib.request
 31import urllib.error
 32import os
 33from typing import Union
 34from urllib.parse import quote
 35
 36from logger.utils.das_record import DASRecord  # noqa: E402
 37from logger.writers.writer import Writer  # noqa: E402
 38
 39
 40class GrafanaLiveWriter(Writer):
 41    """
 42    Write data records to Grafana Live via HTTP Push using InfluxDB Line Protocol.
 43
 44    Grafana Live supports ingesting data via InfluxDB Line Protocol at:
 45        POST /api/live/push/{stream_id}
 46
 47    The 'stream_id' passed during initialization is treated as the 'base_stream'.
 48    Records are written to dynamic streams constructed as:
 49        {base_stream}/{data_id}/{message_type}
 50
 51    This allows a single Writer to populate multiple Grafana Live channels
 52    based on the content of the records.
 53
 54    Example:
 55        writer = GrafanaLiveWriter(
 56            host='localhost:3000',
 57            stream_id='openrvdas',  # Base stream ID
 58            token_file='/etc/secrets/grafana_token'
 59        )
 60        # A record with data_id='mru' and message_type='rotation' will be pushed to:
 61        # /api/live/push/openrvdas/mru/rotation
 62    """
 63
 64    def __init__(self, host, stream_id, api_token=None, token_file=None,
 65                 secure=False, measurement_name=None, batch_size=1,
 66                 queue_size=1000, **kwargs):
 67        """
 68        Initialize GrafanaLiveWriter.
 69
 70        Args:
 71            host (str): Grafana host (e.g., 'localhost:3000')
 72            stream_id (str): Base Stream ID (e.g., 'openrvdas')
 73            api_token (str): Direct token string (Low priority security).
 74            token_file (str): Path to file containing token (High priority security).
 75            secure (bool): Use HTTPS instead of HTTP
 76            measurement_name (str): Override measurement name (uses message_type if None)
 77            batch_size (int): Number of records to batch (1 = no batching)
 78            queue_size (int): Maximum queue size before dropping records
 79            quiet (bool): Suppress routine logging
 80        """
 81        super().__init__(**kwargs)  # processes 'quiet' and type hints
 82
 83        # -----------------------------------------------------------
 84        # SECURITY LOGIC: File > Env > Argument
 85        # -----------------------------------------------------------
 86        self.api_token = None
 87
 88        # 1. Try reading from a secure file (Best)
 89        if token_file:
 90            try:
 91                # Expand ~ to user home if necessary
 92                expanded_path = os.path.expanduser(token_file)
 93                if os.path.exists(expanded_path):
 94                    with open(expanded_path, 'r') as f:
 95                        self.api_token = f.read().strip()
 96                else:
 97                    logging.warning(f"Grafana token_file not found: {token_file}")
 98            except IOError as e:
 99                logging.error(f"Could not read Grafana token file: {e}")
100
101        # 2. Try Environment Variable (Good)
102        if not self.api_token:
103            self.api_token = os.environ.get('GRAFANA_API_TOKEN')
104
105        # 3. Try direct argument (Development only)
106        if not self.api_token:
107            self.api_token = api_token
108
109        # -----------------------------------------------------------
110
111        if not host or not stream_id or not self.api_token:
112            raise RuntimeError(
113                'GrafanaLiveWriter requires host, stream_id, and a valid api_token '
114                '(provided via token_file, GRAFANA_API_TOKEN env var, or api_token arg).'
115            )
116
117        # Sanitize Host
118        self.host = host.strip()
119        for prefix in ['http://', 'https://', 'ws://', 'wss://']:
120            if self.host.startswith(prefix):
121                self.host = self.host[len(prefix):]
122        if self.host.endswith('/'):
123            self.host = self.host[:-1]
124
125        # Store Base Stream ID
126        raw_id = str(stream_id).strip()
127        if raw_id.endswith('/'):
128            raw_id = raw_id[:-1]
129
130        # We store the raw base ID here. Construction happens in write()
131        self.base_stream_id = raw_id
132
133        self.api_token = self.api_token.strip()
134        self.measurement_name = measurement_name
135        self.batch_size = max(1, batch_size)
136        self.protocol = 'https' if secure else 'http'
137
138        # Base API URL
139        self.base_api_url = f'{self.protocol}://{self.host}/api/live/push'
140
141        logging.info(f'GrafanaLiveWriter initialized. Host: {self.host}, '
142                     f'Base Stream: {self.base_stream_id}')
143        logging.info(f'  Batch size: {batch_size}')
144
145        # Queue holds tuples: (target_stream_id, payload_string)
146        self.queue = queue.Queue(maxsize=queue_size)
147
148        # Statistics
149        self.stats = {
150            'sent': 0,
151            'dropped': 0,
152            'errors': 0,
153            'last_error': None
154        }
155        self.stats_lock = threading.Lock()
156
157        # Background worker
158        self.stop_event = threading.Event()
159        self.thread = threading.Thread(target=self._http_worker, daemon=True)
160        self.thread.start()
161
162    def _http_worker(self):
163        """Background worker that sends batched data to Grafana."""
164        # Batches is a dict mapping stream_id -> list of payload strings
165        # Example: { 'openrvdas/mru/rot': ['line1', 'line2'], 'openrvdas/pos/loc': ['line3'] }
166        batches = {}
167        pending_count = 0
168        last_send = time.time()
169
170        while not self.stop_event.is_set():
171            try:
172                # Get item with timeout to allow checking stop_event
173                try:
174                    # Queue items are (stream_id, payload_str)
175                    stream_id, payload_str = self.queue.get(timeout=1.0)
176
177                    if stream_id not in batches:
178                        batches[stream_id] = []
179
180                    batches[stream_id].append(payload_str)
181                    pending_count += 1
182                    self.queue.task_done()
183                except queue.Empty:
184                    pass
185
186                # Send batches if threshold reached or timeout elapsed
187                time_since_last = time.time() - last_send
188                should_send = (pending_count >= self.batch_size or
189                               (pending_count > 0 and time_since_last > 1.0))
190
191                if should_send:
192                    # Send each stream's batch separately
193                    for target_stream, batch_list in batches.items():
194                        if batch_list:
195                            self._send_batch(target_stream, batch_list)
196
197                    # Reset state
198                    batches = {}
199                    pending_count = 0
200                    last_send = time.time()
201
202            except Exception as e:
203                logging.error(f'Unexpected error in GrafanaLiveWriter worker: {e}')
204                # Don't tight loop on error
205                time.sleep(1)
206
207        # Send any remaining batches on shutdown
208        for target_stream, batch_list in batches.items():
209            if batch_list:
210                self._send_batch(target_stream, batch_list)
211
212    def _send_batch(self, stream_id, batch):
213        """Send a batch of line protocol records to a specific Grafana stream."""
214        safe_stream_id = quote(stream_id, safe='')
215        url = f"{self.base_api_url}/{safe_stream_id}"
216
217        try:
218            # Join batch with newlines
219            payload = '\n'.join(batch)
220            data_bytes = payload.encode('utf-8')
221
222            req = urllib.request.Request(url, data=data_bytes, method='POST')
223            req.add_header('Authorization', f'Bearer {self.api_token}')
224            req.add_header('Content-Type', 'text/plain')
225
226            with urllib.request.urlopen(req, timeout=5) as response:
227                if response.status == 204 or response.status == 200:
228                    with self.stats_lock:
229                        self.stats['sent'] += len(batch)
230                    logging.debug(f'Sent {len(batch)} records to Grafana stream {stream_id}')
231
232        except urllib.error.HTTPError as e:
233            error_msg = f'HTTP {e.code}: {e.reason}'
234            with self.stats_lock:
235                self.stats['errors'] += 1
236                self.stats['last_error'] = error_msg
237
238            logging.error(f'Grafana Push Error {error_msg} | URL: {url}')
239
240            # Read error body for details
241            try:
242                error_body = e.read().decode('utf-8')
243                logging.error(f'Error details: {error_body}')
244            except Exception:
245                pass
246
247            if e.code in (401, 403):
248                logging.error('Authentication error - check API token')
249                time.sleep(5)
250            elif e.code == 404:
251                logging.error('Endpoint not found - check Grafana version supports Live')
252                time.sleep(5)
253
254        except urllib.error.URLError as e:
255            error_msg = f'Connection error: {e.reason}'
256            with self.stats_lock:
257                self.stats['errors'] += 1
258                self.stats['last_error'] = error_msg
259
260            logging.error(f'Grafana Connection Error: {e.reason}')
261            time.sleep(1)
262
263        except Exception as e:
264            error_msg = str(e)
265            with self.stats_lock:
266                self.stats['errors'] += 1
267                self.stats['last_error'] = error_msg
268
269            logging.error(f'Unexpected error sending to Grafana: {e}')
270
271    def _normalize_record(self, record):
272        """Extract data_id, message_type, timestamp, and fields from record."""
273        if isinstance(record, str):
274            try:
275                record = DASRecord(json_str=record)
276            except json.JSONDecodeError:
277                return None, None, None, None
278
279        if isinstance(record, dict):
280            msg_type = record.get('message_type')
281            ts = record.get('timestamp', time.time())
282            did = record.get('data_id')
283            fields = record.get('fields', record)
284
285            return did, msg_type, ts, fields
286
287        if isinstance(record, DASRecord):
288            return record.data_id, record.message_type, record.timestamp, record.fields
289
290        return None, None, None, None
291
292    def _escape_key(self, key):
293        """Escape special characters in tag/field keys."""
294        return key.replace(',', '\\,').replace('=', '\\=').replace(' ', '\\ ')
295
296    def _escape_tag_value(self, value):
297        """Escape special characters in tag values."""
298        return str(value).replace(',', '\\,').replace('=', '\\=').replace(' ', '\\ ')
299
300    def _format_line_protocol(self, measurement, fields, timestamp, tags=None):
301        """
302        Format data in InfluxDB Line Protocol.
303
304        Format: measurement[,tag=value...] field=value[,field=value...] [timestamp]
305        """
306        if not fields:
307            return None
308
309        # Format fields
310        field_parts = []
311        for k, v in fields.items():
312            k_escaped = self._escape_key(k)
313
314            # Format value based on type
315            if isinstance(v, str):
316                v_escaped = v.replace('"', '\\"')
317                field_parts.append(f'{k_escaped}="{v_escaped}"')
318            elif isinstance(v, bool):
319                field_parts.append(f'{k_escaped}={str(v).upper()}')
320            elif isinstance(v, int):
321                field_parts.append(f'{k_escaped}={v}i')
322            elif isinstance(v, float):
323                field_parts.append(f'{k_escaped}={v}')
324            else:
325                v_escaped = str(v).replace('"', '\\"')
326                field_parts.append(f'{k_escaped}="{v_escaped}"')
327
328        if not field_parts:
329            return None
330
331        # Escape and format measurement name
332        measurement = self._escape_key(measurement.replace(' ', '_'))
333
334        # Format tags if provided
335        tag_str = ''
336        if tags:
337            tag_parts = [f'{self._escape_key(k)}={self._escape_tag_value(v)}'
338                         for k, v in tags.items()]
339            if tag_parts:
340                tag_str = ',' + ','.join(tag_parts)
341
342        # Format fields
343        field_str = ','.join(field_parts)
344
345        # Timestamp in nanoseconds
346        ts_ns = int(timestamp * 1e9)
347
348        return f'{measurement}{tag_str} {field_str} {ts_ns}'
349
350    def write(self, record: Union[DASRecord, dict, str]):
351        """
352        Write a record to Grafana Live.
353
354        The stream ID for the record is constructed as:
355            {base_stream}/{data_id}/{message_type}
356
357        Args:
358            record: DASRecord, dict, or JSON string
359        """
360        # See if it's something we can process, and if not, try digesting
361        if not self.can_process_record(record):  # inherited from BaseModule()
362            self.digest_record(record)  # inherited from BaseModule()
363            return
364
365        data_id, message_type, timestamp, fields = self._normalize_record(record)
366
367        if not fields:
368            return
369
370        # Determine measurement name (keep existing logic for Line Protocol)
371        measurement = message_type or self.measurement_name or data_id or 'default'
372
373        # Optional: Add data_id as a tag for better querying
374        tags = {'data_id': data_id} if data_id else None
375
376        # Format payload
377        payload = self._format_line_protocol(measurement, fields, timestamp, tags)
378
379        if payload:
380            # Honestly, this section is a bit voodoo. The current setup works
381            # both when there is a message_id and when there isn't, and I'm not
382            # sure why.
383
384            # Construct dynamic Stream ID
385            # Safe quoting of components to ensure valid URL structure
386            # Default to 'unknown' if parts are missing to avoid malformed URLs
387            safe_data_id = quote(str(data_id or 'unknown'), safe='')
388
389            # The message_id gets folded in by _format_line_protocol(), so
390            # don't need to duplicate (Gemini-written) code below
391            #safe_msg_type = quote(str(message_type or 'unknown'), safe='')
392            #target_stream_id = (f"{self.base_stream_id}/{safe_data_id}/"
393            #                    f"{safe_msg_type}")
394
395            # Without the logic below, we get duplicate data_ids when there's
396            # no message_id. I...don't understand, but I'm going with it.
397            if message_type:
398                target_stream_id = f"{self.base_stream_id}/{safe_data_id}"
399            else:
400                target_stream_id = f"{self.base_stream_id}"
401
402            try:
403                # Put tuple (stream_id, payload) into queue
404                self.queue.put_nowait((target_stream_id, payload))
405            except queue.Full:
406                with self.stats_lock:
407                    self.stats['dropped'] += 1
408                logging.warning('GrafanaLiveWriter queue full; dropping record')
409
410    def get_stats(self):
411        """Get writer statistics."""
412        with self.stats_lock:
413            return self.stats.copy()
414
415    def stop(self):
416        """Gracefully stop the writer."""
417        logging.info('Stopping GrafanaLiveWriter...')
418        self.stop_event.set()
419        self.thread.join(timeout=5)
420        logging.info(f'GrafanaLiveWriter stopped. Stats: {self.get_stats()}')
class GrafanaLiveWriter(logger.writers.writer.Writer):
 41class GrafanaLiveWriter(Writer):
 42    """
 43    Write data records to Grafana Live via HTTP Push using InfluxDB Line Protocol.
 44
 45    Grafana Live supports ingesting data via InfluxDB Line Protocol at:
 46        POST /api/live/push/{stream_id}
 47
 48    The 'stream_id' passed during initialization is treated as the 'base_stream'.
 49    Records are written to dynamic streams constructed as:
 50        {base_stream}/{data_id}/{message_type}
 51
 52    This allows a single Writer to populate multiple Grafana Live channels
 53    based on the content of the records.
 54
 55    Example:
 56        writer = GrafanaLiveWriter(
 57            host='localhost:3000',
 58            stream_id='openrvdas',  # Base stream ID
 59            token_file='/etc/secrets/grafana_token'
 60        )
 61        # A record with data_id='mru' and message_type='rotation' will be pushed to:
 62        # /api/live/push/openrvdas/mru/rotation
 63    """
 64
 65    def __init__(self, host, stream_id, api_token=None, token_file=None,
 66                 secure=False, measurement_name=None, batch_size=1,
 67                 queue_size=1000, **kwargs):
 68        """
 69        Initialize GrafanaLiveWriter.
 70
 71        Args:
 72            host (str): Grafana host (e.g., 'localhost:3000')
 73            stream_id (str): Base Stream ID (e.g., 'openrvdas')
 74            api_token (str): Direct token string (Low priority security).
 75            token_file (str): Path to file containing token (High priority security).
 76            secure (bool): Use HTTPS instead of HTTP
 77            measurement_name (str): Override measurement name (uses message_type if None)
 78            batch_size (int): Number of records to batch (1 = no batching)
 79            queue_size (int): Maximum queue size before dropping records
 80            quiet (bool): Suppress routine logging
 81        """
 82        super().__init__(**kwargs)  # processes 'quiet' and type hints
 83
 84        # -----------------------------------------------------------
 85        # SECURITY LOGIC: File > Env > Argument
 86        # -----------------------------------------------------------
 87        self.api_token = None
 88
 89        # 1. Try reading from a secure file (Best)
 90        if token_file:
 91            try:
 92                # Expand ~ to user home if necessary
 93                expanded_path = os.path.expanduser(token_file)
 94                if os.path.exists(expanded_path):
 95                    with open(expanded_path, 'r') as f:
 96                        self.api_token = f.read().strip()
 97                else:
 98                    logging.warning(f"Grafana token_file not found: {token_file}")
 99            except IOError as e:
100                logging.error(f"Could not read Grafana token file: {e}")
101
102        # 2. Try Environment Variable (Good)
103        if not self.api_token:
104            self.api_token = os.environ.get('GRAFANA_API_TOKEN')
105
106        # 3. Try direct argument (Development only)
107        if not self.api_token:
108            self.api_token = api_token
109
110        # -----------------------------------------------------------
111
112        if not host or not stream_id or not self.api_token:
113            raise RuntimeError(
114                'GrafanaLiveWriter requires host, stream_id, and a valid api_token '
115                '(provided via token_file, GRAFANA_API_TOKEN env var, or api_token arg).'
116            )
117
118        # Sanitize Host
119        self.host = host.strip()
120        for prefix in ['http://', 'https://', 'ws://', 'wss://']:
121            if self.host.startswith(prefix):
122                self.host = self.host[len(prefix):]
123        if self.host.endswith('/'):
124            self.host = self.host[:-1]
125
126        # Store Base Stream ID
127        raw_id = str(stream_id).strip()
128        if raw_id.endswith('/'):
129            raw_id = raw_id[:-1]
130
131        # We store the raw base ID here. Construction happens in write()
132        self.base_stream_id = raw_id
133
134        self.api_token = self.api_token.strip()
135        self.measurement_name = measurement_name
136        self.batch_size = max(1, batch_size)
137        self.protocol = 'https' if secure else 'http'
138
139        # Base API URL
140        self.base_api_url = f'{self.protocol}://{self.host}/api/live/push'
141
142        logging.info(f'GrafanaLiveWriter initialized. Host: {self.host}, '
143                     f'Base Stream: {self.base_stream_id}')
144        logging.info(f'  Batch size: {batch_size}')
145
146        # Queue holds tuples: (target_stream_id, payload_string)
147        self.queue = queue.Queue(maxsize=queue_size)
148
149        # Statistics
150        self.stats = {
151            'sent': 0,
152            'dropped': 0,
153            'errors': 0,
154            'last_error': None
155        }
156        self.stats_lock = threading.Lock()
157
158        # Background worker
159        self.stop_event = threading.Event()
160        self.thread = threading.Thread(target=self._http_worker, daemon=True)
161        self.thread.start()
162
163    def _http_worker(self):
164        """Background worker that sends batched data to Grafana."""
165        # Batches is a dict mapping stream_id -> list of payload strings
166        # Example: { 'openrvdas/mru/rot': ['line1', 'line2'], 'openrvdas/pos/loc': ['line3'] }
167        batches = {}
168        pending_count = 0
169        last_send = time.time()
170
171        while not self.stop_event.is_set():
172            try:
173                # Get item with timeout to allow checking stop_event
174                try:
175                    # Queue items are (stream_id, payload_str)
176                    stream_id, payload_str = self.queue.get(timeout=1.0)
177
178                    if stream_id not in batches:
179                        batches[stream_id] = []
180
181                    batches[stream_id].append(payload_str)
182                    pending_count += 1
183                    self.queue.task_done()
184                except queue.Empty:
185                    pass
186
187                # Send batches if threshold reached or timeout elapsed
188                time_since_last = time.time() - last_send
189                should_send = (pending_count >= self.batch_size or
190                               (pending_count > 0 and time_since_last > 1.0))
191
192                if should_send:
193                    # Send each stream's batch separately
194                    for target_stream, batch_list in batches.items():
195                        if batch_list:
196                            self._send_batch(target_stream, batch_list)
197
198                    # Reset state
199                    batches = {}
200                    pending_count = 0
201                    last_send = time.time()
202
203            except Exception as e:
204                logging.error(f'Unexpected error in GrafanaLiveWriter worker: {e}')
205                # Don't tight loop on error
206                time.sleep(1)
207
208        # Send any remaining batches on shutdown
209        for target_stream, batch_list in batches.items():
210            if batch_list:
211                self._send_batch(target_stream, batch_list)
212
213    def _send_batch(self, stream_id, batch):
214        """Send a batch of line protocol records to a specific Grafana stream."""
215        safe_stream_id = quote(stream_id, safe='')
216        url = f"{self.base_api_url}/{safe_stream_id}"
217
218        try:
219            # Join batch with newlines
220            payload = '\n'.join(batch)
221            data_bytes = payload.encode('utf-8')
222
223            req = urllib.request.Request(url, data=data_bytes, method='POST')
224            req.add_header('Authorization', f'Bearer {self.api_token}')
225            req.add_header('Content-Type', 'text/plain')
226
227            with urllib.request.urlopen(req, timeout=5) as response:
228                if response.status == 204 or response.status == 200:
229                    with self.stats_lock:
230                        self.stats['sent'] += len(batch)
231                    logging.debug(f'Sent {len(batch)} records to Grafana stream {stream_id}')
232
233        except urllib.error.HTTPError as e:
234            error_msg = f'HTTP {e.code}: {e.reason}'
235            with self.stats_lock:
236                self.stats['errors'] += 1
237                self.stats['last_error'] = error_msg
238
239            logging.error(f'Grafana Push Error {error_msg} | URL: {url}')
240
241            # Read error body for details
242            try:
243                error_body = e.read().decode('utf-8')
244                logging.error(f'Error details: {error_body}')
245            except Exception:
246                pass
247
248            if e.code in (401, 403):
249                logging.error('Authentication error - check API token')
250                time.sleep(5)
251            elif e.code == 404:
252                logging.error('Endpoint not found - check Grafana version supports Live')
253                time.sleep(5)
254
255        except urllib.error.URLError as e:
256            error_msg = f'Connection error: {e.reason}'
257            with self.stats_lock:
258                self.stats['errors'] += 1
259                self.stats['last_error'] = error_msg
260
261            logging.error(f'Grafana Connection Error: {e.reason}')
262            time.sleep(1)
263
264        except Exception as e:
265            error_msg = str(e)
266            with self.stats_lock:
267                self.stats['errors'] += 1
268                self.stats['last_error'] = error_msg
269
270            logging.error(f'Unexpected error sending to Grafana: {e}')
271
272    def _normalize_record(self, record):
273        """Extract data_id, message_type, timestamp, and fields from record."""
274        if isinstance(record, str):
275            try:
276                record = DASRecord(json_str=record)
277            except json.JSONDecodeError:
278                return None, None, None, None
279
280        if isinstance(record, dict):
281            msg_type = record.get('message_type')
282            ts = record.get('timestamp', time.time())
283            did = record.get('data_id')
284            fields = record.get('fields', record)
285
286            return did, msg_type, ts, fields
287
288        if isinstance(record, DASRecord):
289            return record.data_id, record.message_type, record.timestamp, record.fields
290
291        return None, None, None, None
292
293    def _escape_key(self, key):
294        """Escape special characters in tag/field keys."""
295        return key.replace(',', '\\,').replace('=', '\\=').replace(' ', '\\ ')
296
297    def _escape_tag_value(self, value):
298        """Escape special characters in tag values."""
299        return str(value).replace(',', '\\,').replace('=', '\\=').replace(' ', '\\ ')
300
301    def _format_line_protocol(self, measurement, fields, timestamp, tags=None):
302        """
303        Format data in InfluxDB Line Protocol.
304
305        Format: measurement[,tag=value...] field=value[,field=value...] [timestamp]
306        """
307        if not fields:
308            return None
309
310        # Format fields
311        field_parts = []
312        for k, v in fields.items():
313            k_escaped = self._escape_key(k)
314
315            # Format value based on type
316            if isinstance(v, str):
317                v_escaped = v.replace('"', '\\"')
318                field_parts.append(f'{k_escaped}="{v_escaped}"')
319            elif isinstance(v, bool):
320                field_parts.append(f'{k_escaped}={str(v).upper()}')
321            elif isinstance(v, int):
322                field_parts.append(f'{k_escaped}={v}i')
323            elif isinstance(v, float):
324                field_parts.append(f'{k_escaped}={v}')
325            else:
326                v_escaped = str(v).replace('"', '\\"')
327                field_parts.append(f'{k_escaped}="{v_escaped}"')
328
329        if not field_parts:
330            return None
331
332        # Escape and format measurement name
333        measurement = self._escape_key(measurement.replace(' ', '_'))
334
335        # Format tags if provided
336        tag_str = ''
337        if tags:
338            tag_parts = [f'{self._escape_key(k)}={self._escape_tag_value(v)}'
339                         for k, v in tags.items()]
340            if tag_parts:
341                tag_str = ',' + ','.join(tag_parts)
342
343        # Format fields
344        field_str = ','.join(field_parts)
345
346        # Timestamp in nanoseconds
347        ts_ns = int(timestamp * 1e9)
348
349        return f'{measurement}{tag_str} {field_str} {ts_ns}'
350
351    def write(self, record: Union[DASRecord, dict, str]):
352        """
353        Write a record to Grafana Live.
354
355        The stream ID for the record is constructed as:
356            {base_stream}/{data_id}/{message_type}
357
358        Args:
359            record: DASRecord, dict, or JSON string
360        """
361        # See if it's something we can process, and if not, try digesting
362        if not self.can_process_record(record):  # inherited from BaseModule()
363            self.digest_record(record)  # inherited from BaseModule()
364            return
365
366        data_id, message_type, timestamp, fields = self._normalize_record(record)
367
368        if not fields:
369            return
370
371        # Determine measurement name (keep existing logic for Line Protocol)
372        measurement = message_type or self.measurement_name or data_id or 'default'
373
374        # Optional: Add data_id as a tag for better querying
375        tags = {'data_id': data_id} if data_id else None
376
377        # Format payload
378        payload = self._format_line_protocol(measurement, fields, timestamp, tags)
379
380        if payload:
381            # Honestly, this section is a bit voodoo. The current setup works
382            # both when there is a message_id and when there isn't, and I'm not
383            # sure why.
384
385            # Construct dynamic Stream ID
386            # Safe quoting of components to ensure valid URL structure
387            # Default to 'unknown' if parts are missing to avoid malformed URLs
388            safe_data_id = quote(str(data_id or 'unknown'), safe='')
389
390            # The message_id gets folded in by _format_line_protocol(), so
391            # don't need to duplicate (Gemini-written) code below
392            #safe_msg_type = quote(str(message_type or 'unknown'), safe='')
393            #target_stream_id = (f"{self.base_stream_id}/{safe_data_id}/"
394            #                    f"{safe_msg_type}")
395
396            # Without the logic below, we get duplicate data_ids when there's
397            # no message_id. I...don't understand, but I'm going with it.
398            if message_type:
399                target_stream_id = f"{self.base_stream_id}/{safe_data_id}"
400            else:
401                target_stream_id = f"{self.base_stream_id}"
402
403            try:
404                # Put tuple (stream_id, payload) into queue
405                self.queue.put_nowait((target_stream_id, payload))
406            except queue.Full:
407                with self.stats_lock:
408                    self.stats['dropped'] += 1
409                logging.warning('GrafanaLiveWriter queue full; dropping record')
410
411    def get_stats(self):
412        """Get writer statistics."""
413        with self.stats_lock:
414            return self.stats.copy()
415
416    def stop(self):
417        """Gracefully stop the writer."""
418        logging.info('Stopping GrafanaLiveWriter...')
419        self.stop_event.set()
420        self.thread.join(timeout=5)
421        logging.info(f'GrafanaLiveWriter stopped. Stats: {self.get_stats()}')

Write data records to Grafana Live via HTTP Push using InfluxDB Line Protocol.

Grafana Live supports ingesting data via InfluxDB Line Protocol at: POST /api/live/push/{stream_id}

The 'stream_id' passed during initialization is treated as the 'base_stream'. Records are written to dynamic streams constructed as: {base_stream}/{data_id}/{message_type}

This allows a single Writer to populate multiple Grafana Live channels based on the content of the records.

Example: writer = GrafanaLiveWriter( host='localhost:3000', stream_id='openrvdas', # Base stream ID token_file='/etc/secrets/grafana_token' ) # A record with data_id='mru' and message_type='rotation' will be pushed to: # /api/live/push/openrvdas/mru/rotation

GrafanaLiveWriter( host, stream_id, api_token=None, token_file=None, secure=False, measurement_name=None, batch_size=1, queue_size=1000, **kwargs)
 65    def __init__(self, host, stream_id, api_token=None, token_file=None,
 66                 secure=False, measurement_name=None, batch_size=1,
 67                 queue_size=1000, **kwargs):
 68        """
 69        Initialize GrafanaLiveWriter.
 70
 71        Args:
 72            host (str): Grafana host (e.g., 'localhost:3000')
 73            stream_id (str): Base Stream ID (e.g., 'openrvdas')
 74            api_token (str): Direct token string (Low priority security).
 75            token_file (str): Path to file containing token (High priority security).
 76            secure (bool): Use HTTPS instead of HTTP
 77            measurement_name (str): Override measurement name (uses message_type if None)
 78            batch_size (int): Number of records to batch (1 = no batching)
 79            queue_size (int): Maximum queue size before dropping records
 80            quiet (bool): Suppress routine logging
 81        """
 82        super().__init__(**kwargs)  # processes 'quiet' and type hints
 83
 84        # -----------------------------------------------------------
 85        # SECURITY LOGIC: File > Env > Argument
 86        # -----------------------------------------------------------
 87        self.api_token = None
 88
 89        # 1. Try reading from a secure file (Best)
 90        if token_file:
 91            try:
 92                # Expand ~ to user home if necessary
 93                expanded_path = os.path.expanduser(token_file)
 94                if os.path.exists(expanded_path):
 95                    with open(expanded_path, 'r') as f:
 96                        self.api_token = f.read().strip()
 97                else:
 98                    logging.warning(f"Grafana token_file not found: {token_file}")
 99            except IOError as e:
100                logging.error(f"Could not read Grafana token file: {e}")
101
102        # 2. Try Environment Variable (Good)
103        if not self.api_token:
104            self.api_token = os.environ.get('GRAFANA_API_TOKEN')
105
106        # 3. Try direct argument (Development only)
107        if not self.api_token:
108            self.api_token = api_token
109
110        # -----------------------------------------------------------
111
112        if not host or not stream_id or not self.api_token:
113            raise RuntimeError(
114                'GrafanaLiveWriter requires host, stream_id, and a valid api_token '
115                '(provided via token_file, GRAFANA_API_TOKEN env var, or api_token arg).'
116            )
117
118        # Sanitize Host
119        self.host = host.strip()
120        for prefix in ['http://', 'https://', 'ws://', 'wss://']:
121            if self.host.startswith(prefix):
122                self.host = self.host[len(prefix):]
123        if self.host.endswith('/'):
124            self.host = self.host[:-1]
125
126        # Store Base Stream ID
127        raw_id = str(stream_id).strip()
128        if raw_id.endswith('/'):
129            raw_id = raw_id[:-1]
130
131        # We store the raw base ID here. Construction happens in write()
132        self.base_stream_id = raw_id
133
134        self.api_token = self.api_token.strip()
135        self.measurement_name = measurement_name
136        self.batch_size = max(1, batch_size)
137        self.protocol = 'https' if secure else 'http'
138
139        # Base API URL
140        self.base_api_url = f'{self.protocol}://{self.host}/api/live/push'
141
142        logging.info(f'GrafanaLiveWriter initialized. Host: {self.host}, '
143                     f'Base Stream: {self.base_stream_id}')
144        logging.info(f'  Batch size: {batch_size}')
145
146        # Queue holds tuples: (target_stream_id, payload_string)
147        self.queue = queue.Queue(maxsize=queue_size)
148
149        # Statistics
150        self.stats = {
151            'sent': 0,
152            'dropped': 0,
153            'errors': 0,
154            'last_error': None
155        }
156        self.stats_lock = threading.Lock()
157
158        # Background worker
159        self.stop_event = threading.Event()
160        self.thread = threading.Thread(target=self._http_worker, daemon=True)
161        self.thread.start()

Initialize GrafanaLiveWriter.

Args: host (str): Grafana host (e.g., 'localhost:3000') stream_id (str): Base Stream ID (e.g., 'openrvdas') api_token (str): Direct token string (Low priority security). token_file (str): Path to file containing token (High priority security). secure (bool): Use HTTPS instead of HTTP measurement_name (str): Override measurement name (uses message_type if None) batch_size (int): Number of records to batch (1 = no batching) queue_size (int): Maximum queue size before dropping records quiet (bool): Suppress routine logging

api_token
host
base_stream_id
measurement_name
batch_size
protocol
base_api_url
queue
stats
stats_lock
stop_event
thread
def write(self, record: Union[logger.utils.das_record.DASRecord, dict, str]):
351    def write(self, record: Union[DASRecord, dict, str]):
352        """
353        Write a record to Grafana Live.
354
355        The stream ID for the record is constructed as:
356            {base_stream}/{data_id}/{message_type}
357
358        Args:
359            record: DASRecord, dict, or JSON string
360        """
361        # See if it's something we can process, and if not, try digesting
362        if not self.can_process_record(record):  # inherited from BaseModule()
363            self.digest_record(record)  # inherited from BaseModule()
364            return
365
366        data_id, message_type, timestamp, fields = self._normalize_record(record)
367
368        if not fields:
369            return
370
371        # Determine measurement name (keep existing logic for Line Protocol)
372        measurement = message_type or self.measurement_name or data_id or 'default'
373
374        # Optional: Add data_id as a tag for better querying
375        tags = {'data_id': data_id} if data_id else None
376
377        # Format payload
378        payload = self._format_line_protocol(measurement, fields, timestamp, tags)
379
380        if payload:
381            # Honestly, this section is a bit voodoo. The current setup works
382            # both when there is a message_id and when there isn't, and I'm not
383            # sure why.
384
385            # Construct dynamic Stream ID
386            # Safe quoting of components to ensure valid URL structure
387            # Default to 'unknown' if parts are missing to avoid malformed URLs
388            safe_data_id = quote(str(data_id or 'unknown'), safe='')
389
390            # The message_id gets folded in by _format_line_protocol(), so
391            # don't need to duplicate (Gemini-written) code below
392            #safe_msg_type = quote(str(message_type or 'unknown'), safe='')
393            #target_stream_id = (f"{self.base_stream_id}/{safe_data_id}/"
394            #                    f"{safe_msg_type}")
395
396            # Without the logic below, we get duplicate data_ids when there's
397            # no message_id. I...don't understand, but I'm going with it.
398            if message_type:
399                target_stream_id = f"{self.base_stream_id}/{safe_data_id}"
400            else:
401                target_stream_id = f"{self.base_stream_id}"
402
403            try:
404                # Put tuple (stream_id, payload) into queue
405                self.queue.put_nowait((target_stream_id, payload))
406            except queue.Full:
407                with self.stats_lock:
408                    self.stats['dropped'] += 1
409                logging.warning('GrafanaLiveWriter queue full; dropping record')

Write a record to Grafana Live.

The stream ID for the record is constructed as: {base_stream}/{data_id}/{message_type}

Args: record: DASRecord, dict, or JSON string

def get_stats(self):
411    def get_stats(self):
412        """Get writer statistics."""
413        with self.stats_lock:
414            return self.stats.copy()

Get writer statistics.

def stop(self):
416    def stop(self):
417        """Gracefully stop the writer."""
418        logging.info('Stopping GrafanaLiveWriter...')
419        self.stop_event.set()
420        self.thread.join(timeout=5)
421        logging.info(f'GrafanaLiveWriter stopped. Stats: {self.get_stats()}')

Gracefully stop the writer.