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:
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.
GRAFANA_API_TOKEN (GOOD): Environment variable. Better than hardcoding, but visible to root.
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()}')
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
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
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