logryn 1.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ """
2
+ Advanced High-Availability Logging Library
3
+ ==========================================
4
+ A robust, asynchronous, and thread-safe logging framework designed for
5
+ long-running pipelines. It supports RAM caching, Database batch writing,
6
+ and graceful shutdowns under heavy concurrent loads.
7
+
8
+ Core Components:
9
+ - MainLog: The central async logging orchestrator.
10
+ - project_log_decorator: External wrapper for pipeline functions.
11
+ - ThreadConfigManager: Thread identity and configuration management.
12
+ - setup_global_error_handler: OS-level signal and crash handling.
13
+ """
14
+
15
+ __version__ = "1.0.0b1"
16
+ __author__ = "Elad Segev"
17
+
18
+ # 1. Core Logger & Decorators
19
+ from .main_logger import (
20
+ MainLog,
21
+ project_log_decorator
22
+ )
23
+
24
+ # 2. Thread Management
25
+ from .thread_config_manager import ThreadConfigManager
26
+
27
+ # 3. System Stability & Crash Handling
28
+ from .logger_e import setup_global_error_handler
29
+
30
+ # 4. Explicit Public API Definition
31
+ __all__ = [
32
+ "MainLog",
33
+ "project_log_decorator",
34
+ "ThreadConfigManager",
35
+ "setup_global_error_handler",
36
+ "__version__"
37
+ ]
@@ -0,0 +1,553 @@
1
+ from sqlalchemy import create_engine, inspect, text
2
+ from functools import lru_cache
3
+
4
+ @lru_cache(maxsize=32)
5
+ def _get_engine(connection_string: str):
6
+ """
7
+ Returns a cached SQLAlchemy database engine for the specified connection.
8
+
9
+ The engine is configured with connection health checks and connection
10
+ recycling to improve reliability for long-running database operations.
11
+
12
+ :param connection_string: Database connection string used to create the engine.
13
+ :type connection_string: str
14
+
15
+ :return: SQLAlchemy engine associated with the connection string.
16
+ :rtype: Engine
17
+ """
18
+ return create_engine(
19
+ connection_string,
20
+ pool_pre_ping=True,
21
+ pool_recycle=3600,
22
+ )
23
+
24
+
25
+ def _set_connection_string(database_name:str|None, db_config:dict):
26
+ """
27
+ Builds a MariaDB connection string from the provided database configuration.
28
+
29
+ :param database_name: Name of the target database. If omitted, the connection
30
+ string points to the MariaDB server without a database.
31
+ :type database_name: str | None
32
+ :param db_config: Dictionary containing the database connection parameters,
33
+ including user, password, host, and port.
34
+ :type db_config: dict
35
+
36
+ :return: MariaDB SQLAlchemy connection string.
37
+ :rtype: str
38
+ """
39
+ user = db_config.get("user")
40
+ password = db_config.get("password")
41
+ host = db_config.get("host")
42
+ port = db_config.get("port")
43
+ if database_name:
44
+ return f"mariadb+mariadbconnector://{user}:{password}@{host}:{port}/{database_name}"
45
+ return f"mariadb+mariadbconnector://{user}:{password}@{host}:{port}/"
46
+
47
+
48
+ def is_table_exists(table_name: str, database_name: str, db_config: dict) -> bool:
49
+ """
50
+ Checks whether a table exists in the specified database.
51
+
52
+ :param table_name: Name of the table to check.
53
+ :type table_name: str
54
+ :param database_name: Name of the target database.
55
+ :type database_name: str
56
+ :param db_config: Dictionary containing the database connection parameters.
57
+ :type db_config: dict
58
+
59
+ :return: True if the table exists, otherwise False.
60
+ :rtype: bool
61
+ """
62
+ engine = _get_engine(_set_connection_string(database_name, db_config))
63
+ inspector = inspect(engine)
64
+ return inspector.has_table(table_name)
65
+
66
+
67
+ def get_all_tables(database_name: str, db_config: dict) -> list:
68
+ """
69
+ Retrieves all table names from the specified database.
70
+
71
+ :param database_name: Name of the target database.
72
+ :type database_name: str
73
+ :param db_config: Dictionary containing the database connection parameters.
74
+ :type db_config: dict
75
+
76
+ :return: List of table names, or an empty list if the operation fails.
77
+ :rtype: list
78
+ """
79
+ try:
80
+ engine = _get_engine(_set_connection_string(database_name, db_config))
81
+ inspector = inspect(engine)
82
+
83
+ return inspector.get_table_names()
84
+
85
+ except Exception as e:
86
+ print(f"Failed to fetch tables: {e}")
87
+ return []
88
+
89
+
90
+ def is_db_exists(database_name: str, db_config: dict) -> bool:
91
+ """
92
+ Checks whether a database exists on the configured MariaDB server.
93
+
94
+ :param database_name: Name of the database to check.
95
+ :type database_name: str
96
+ :param db_config: Dictionary containing the database connection parameters.
97
+ :type db_config: dict
98
+
99
+ :return: True if the database exists, otherwise False.
100
+ :rtype: bool
101
+ """
102
+
103
+ engine = _get_engine(_set_connection_string(None, db_config))
104
+
105
+ with engine.connect() as conn:
106
+ query_check = text(f"SHOW DATABASES LIKE '{database_name}'")
107
+ result = conn.execute(query_check)
108
+ db_exists = result.fetchone() is not None
109
+
110
+ return db_exists
111
+
112
+
113
+ def create_database_(database_name: str, db_config: dict) -> tuple:
114
+ """
115
+ Creates a database on the configured MariaDB server if it does not already exist.
116
+
117
+ :param database_name: Name of the database to create.
118
+ :type database_name: str
119
+ :param db_config: Dictionary containing the database connection parameters.
120
+ :type db_config: dict
121
+
122
+ :return: Tuple containing a success flag and a status or error message.
123
+ :rtype: tuple
124
+ """
125
+ try:
126
+
127
+ if is_db_exists(database_name=database_name, db_config=db_config):
128
+ return True, 'Existing database'
129
+
130
+ try:
131
+
132
+ engine = _get_engine(_set_connection_string(None, db_config))
133
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn_create:
134
+ conn_create.execute(text(f"CREATE DATABASE {database_name}"))
135
+ return True, 'Database created'
136
+
137
+ except Exception as e_create:
138
+ msg = f"Failed to create database '{database_name}': {e_create}"
139
+ return False, msg
140
+
141
+ except Exception as e_conn:
142
+ mag = f"Failed to connect to MariaDB server: {e_conn}"
143
+ return False, mag
144
+
145
+
146
+ def create_table_(table_name: str, database_name: str, db_config: dict, create_query: str) -> tuple:
147
+ """
148
+ Creates a table in the specified database if it does not already exist.
149
+
150
+ :param table_name: Name of the table to create.
151
+ :type table_name: str
152
+ :param database_name: Name of the target database.
153
+ :type database_name: str
154
+ :param db_config: Dictionary containing the database connection parameters.
155
+ :type db_config: dict
156
+ :param create_query: SQL statement used to create the table.
157
+ :type create_query: str
158
+
159
+ :return: Tuple containing a success flag and a status or error message.
160
+ :rtype: tuple
161
+ """
162
+ try:
163
+ exists_status = is_table_exists(table_name, database_name, db_config)
164
+
165
+ except Exception as e:
166
+ msg = f"Failed to connect or check table: {e}"
167
+ return False, msg
168
+
169
+
170
+ if exists_status is True:
171
+ return True, 'Existing table'
172
+
173
+ try:
174
+ engine = _get_engine(_set_connection_string(database_name, db_config))
175
+
176
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
177
+ conn.execute(text(create_query))
178
+
179
+ return True, 'Table created'
180
+
181
+ except Exception as e:
182
+ mag = f"Failed to create table '{table_name}': {e}"
183
+ return False, mag
184
+
185
+
186
+ def insert_row_(data_dict: dict, table_name: str, database_name: str, db_config: dict) -> tuple:
187
+ """
188
+ Inserts a row into the specified database table.
189
+
190
+ :param data_dict: Dictionary containing column names and their corresponding
191
+ values for the row to insert.
192
+ :type data_dict: dict
193
+ :param table_name: Name of the target table.
194
+ :type table_name: str
195
+ :param database_name: Name of the target database.
196
+ :type database_name: str
197
+ :param db_config: Dictionary containing the database connection parameters.
198
+ :type db_config: dict
199
+
200
+ :return: Tuple containing a success flag and the inserted row ID or an error
201
+ message.
202
+ :rtype: tuple
203
+ """
204
+ try:
205
+ engine = _get_engine(_set_connection_string(database_name, db_config))
206
+
207
+ columns = ", ".join(data_dict.keys())
208
+ placeholders = ", ".join([f":{k}" for k in data_dict.keys()])
209
+ query_string = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
210
+
211
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
212
+ result = conn.execute(text(query_string), data_dict)
213
+ inserted_id = result.lastrowid
214
+
215
+ return True, inserted_id
216
+
217
+ except Exception as e:
218
+ msg = f"Failed to insert row into '{table_name}': {e}"
219
+ return False, msg
220
+
221
+
222
+ def insert_rows_batch_(data_list: list[dict], table_name: str, database_name: str, db_config: dict) -> tuple:
223
+ """
224
+ Inserts multiple rows into the specified database table as a batch operation.
225
+
226
+ :param data_list: List of dictionaries containing column names and their
227
+ corresponding values for each row.
228
+ :type data_list: list[dict]
229
+ :param table_name: Name of the target table.
230
+ :type table_name: str
231
+ :param database_name: Name of the target database.
232
+ :type database_name: str
233
+ :param db_config: Dictionary containing the database connection parameters.
234
+ :type db_config: dict
235
+
236
+ :return: Tuple containing a success flag and a status or error message.
237
+ :rtype: tuple
238
+ """
239
+ if not data_list:
240
+ return True, 'No data to insert'
241
+
242
+ try:
243
+ engine = _get_engine(_set_connection_string(database_name, db_config))
244
+
245
+ columns = ", ".join(data_list[0].keys())
246
+ placeholders = ", ".join([f":{k}" for k in data_list[0].keys()])
247
+ query_string = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
248
+
249
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
250
+ conn.execute(text(query_string), data_list)
251
+
252
+ return True, f'{len(data_list)} rows inserted successfully'
253
+
254
+ except Exception as e:
255
+ msg = f"Failed to insert batch into '{table_name}': {e}"
256
+ return False, msg
257
+
258
+
259
+ def drop_table_(table_name: str, database_name: str, db_config: dict) -> tuple:
260
+ """
261
+ Drops the specified table from the database if it exists.
262
+
263
+ :param table_name: Name of the table to drop.
264
+ :type table_name: str
265
+ :param database_name: Name of the target database.
266
+ :type database_name: str
267
+ :param db_config: Dictionary containing the database connection parameters.
268
+ :type db_config: dict
269
+
270
+ :return: Tuple containing a success flag and a status or error message.
271
+ :rtype: tuple
272
+ """
273
+ try:
274
+ engine = _get_engine(_set_connection_string(database_name, db_config))
275
+
276
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
277
+ conn.execute(text(f"DROP TABLE IF EXISTS {table_name}"))
278
+
279
+ return True, f"Table '{table_name}' dropped successfully"
280
+
281
+ except Exception as e:
282
+ msg = f"Failed to drop table '{table_name}': {e}"
283
+ return False, msg
284
+
285
+
286
+ def drop_database_(database_name: str, db_config: dict) -> tuple:
287
+ """
288
+ Drops the specified database if it exists.
289
+
290
+ :param database_name: Name of the database to drop.
291
+ :type database_name: str
292
+ :param db_config: Dictionary containing the database connection parameters.
293
+ :type db_config: dict
294
+
295
+ :return: Tuple containing a success flag and a status or error message.
296
+ :rtype: tuple
297
+ """
298
+ if not is_db_exists(database_name, db_config):
299
+ return False, f"The database '{database_name}' doesn't exsist "
300
+
301
+ try:
302
+ engine = _get_engine(_set_connection_string(database_name, db_config))
303
+
304
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
305
+ conn.execute(text(f"DROP DATABASE IF EXISTS {database_name}"))
306
+
307
+ return True, f"Database '{database_name}' dropped successfully"
308
+
309
+ except Exception as e:
310
+ msg = f"Failed to drop database '{database_name}': {e}"
311
+ return False, msg
312
+
313
+
314
+ def alter_table_(table_name: str, database_name: str, db_config: dict, alter_query: str) -> tuple:
315
+ """
316
+ Alters the specified table using the provided SQL statement.
317
+
318
+ :param table_name: Name of the table to alter.
319
+ :type table_name: str
320
+ :param database_name: Name of the target database.
321
+ :type database_name: str
322
+ :param db_config: Dictionary containing the database connection parameters.
323
+ :type db_config: dict
324
+ :param alter_query: SQL statement used to alter the table.
325
+ :type alter_query: str
326
+
327
+ :return: Tuple containing a success flag and a status or error message.
328
+ :rtype: tuple
329
+ """
330
+ try:
331
+ engine = _get_engine(_set_connection_string(database_name, db_config))
332
+
333
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
334
+ conn.execute(text(alter_query))
335
+
336
+ return True, f"Table '{table_name}' altered successfully"
337
+
338
+ except Exception as e:
339
+ msg = f"Failed to alter table '{table_name}': {e}"
340
+ return False, msg
341
+
342
+
343
+ def get_row_count_(table_name: str, database_name: str, db_config: dict) -> int:
344
+ """
345
+ Returns the number of rows in the specified database table.
346
+
347
+ :param table_name: Name of the table to count rows from.
348
+ :type table_name: str
349
+ :param database_name: Name of the target database.
350
+ :type database_name: str
351
+ :param db_config: Dictionary containing the database connection parameters.
352
+ :type db_config: dict
353
+
354
+ :return: Number of rows in the table, or 0 if the operation fails.
355
+ :rtype: int
356
+ """
357
+ try:
358
+ engine = _get_engine(_set_connection_string(database_name, db_config))
359
+
360
+ with engine.connect() as conn:
361
+ query = text(f"SELECT COUNT(*) FROM {table_name}")
362
+ result = conn.execute(query)
363
+ count = result.scalar()
364
+
365
+ return int(count) if count is not None else 0
366
+
367
+ except Exception as e:
368
+ print(f"Failed to get row count for '{table_name}': {e}")
369
+ return 0
370
+
371
+
372
+ def delete_table_(table_name: str, database_name: str, db_config: dict) -> tuple:
373
+ """
374
+ Deletes all rows from the specified database table.
375
+
376
+ :param table_name: Name of the table to clear.
377
+ :type table_name: str
378
+ :param database_name: Name of the target database.
379
+ :type database_name: str
380
+ :param db_config: Dictionary containing the database connection parameters.
381
+ :type db_config: dict
382
+
383
+ :return: Tuple containing a success flag and a status or error message.
384
+ :rtype: tuple
385
+ """
386
+ try:
387
+ engine = _get_engine(_set_connection_string(database_name, db_config))
388
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
389
+ conn.execute(text(f"DELETE FROM {table_name}"))
390
+ return True, f"Table '{table_name}' cleared successfully"
391
+ except Exception as e:
392
+ return False, f"Failed to clear table '{table_name}': {e}"
393
+
394
+
395
+ def upsert_rows_batch_(data_list: list[dict], table_name: str, database_name: str, db_config: dict) -> tuple:
396
+ """
397
+ Inserts multiple rows into the specified database table and updates existing
398
+ rows when a duplicate key is encountered.
399
+
400
+ :param data_list: List of dictionaries containing column names and their
401
+ corresponding values for each row.
402
+ :type data_list: list[dict]
403
+ :param table_name: Name of the target table.
404
+ :type table_name: str
405
+ :param database_name: Name of the target database.
406
+ :type database_name: str
407
+ :param db_config: Dictionary containing the database connection parameters.
408
+ :type db_config: dict
409
+
410
+ :return: Tuple containing a success flag and a status or error message.
411
+ :rtype: tuple
412
+ """
413
+ if not data_list:
414
+ return True, 'No data to upsert'
415
+
416
+ try:
417
+ engine = _get_engine(_set_connection_string(database_name, db_config))
418
+
419
+ columns = ", ".join(data_list[0].keys())
420
+ placeholders = ", ".join([f":{k}" for k in data_list[0].keys()])
421
+
422
+
423
+ update_cols = [k for k in data_list[0].keys() if k != 'station_id']
424
+ update_clause = ", ".join([f"{k} = VALUES({k})" for k in update_cols])
425
+
426
+ query_string = f"""
427
+ INSERT INTO {table_name} ({columns})
428
+ VALUES ({placeholders})
429
+ ON DUPLICATE KEY UPDATE {update_clause}
430
+ """
431
+
432
+ with engine.execution_options(isolation_level="AUTOCOMMIT").connect() as conn:
433
+ conn.execute(text(query_string), data_list)
434
+
435
+ return True, f'{len(data_list)} rows upserted successfully'
436
+
437
+ except Exception as e:
438
+ msg = f"Failed to upsert batch into '{table_name}': {e}"
439
+ return False, msg
440
+
441
+
442
+ def select_(table_name: str, database_name: str, db_config: dict, where_dict: str | None = None, logical_op: str = "AND") -> tuple:
443
+ """
444
+ Retrieves rows from the specified database table with an optional filter.
445
+
446
+ :param table_name: Name of the table to query.
447
+ :type table_name: str
448
+ :param database_name: Name of the target database.
449
+ :type database_name: str
450
+ :param db_config: Dictionary containing the database connection parameters.
451
+ :type db_config: dict
452
+ :param where_dict: Optional filter definition used to build the SQL WHERE
453
+ clause.
454
+ :type where_dict: str | None
455
+ :param logical_op: Logical operator used to combine multiple filter
456
+ conditions.
457
+ :type logical_op: str
458
+
459
+ :return: Tuple containing a success flag and the retrieved rows or an error
460
+ message.
461
+ :rtype: tuple
462
+ """
463
+
464
+ query_str = f"SELECT * FROM {table_name}"
465
+
466
+ where_string, query_params = _build_where_clause(where_dict, logical_op)
467
+
468
+
469
+ if where_string:
470
+ query_str += f" WHERE {where_string}"
471
+
472
+ try:
473
+ engine = _get_engine(_set_connection_string(database_name, db_config))
474
+ with engine.connect() as conn:
475
+ result = conn.execute(text(query_str), query_params)
476
+ rows = result.fetchall()
477
+
478
+ return True, rows
479
+
480
+ except Exception as e:
481
+ return False, str(e)
482
+
483
+
484
+ def _build_where_clause(conditions: dict | None, logical_op: str = "AND") -> tuple[str, dict]:
485
+ """
486
+ Constructs a parameterized SQL WHERE clause from a dictionary of conditions.
487
+
488
+ The function converts each condition into a SQL comparison using named
489
+ placeholders and stores the corresponding values separately for safe query
490
+ execution. Conditions may use the default equality operator or explicitly
491
+ specify a supported SQL operator.
492
+
493
+ :param conditions: Dictionary mapping column names to values or
494
+ ``(operator, value)`` tuples.
495
+ :type conditions: dict | None
496
+ :param logical_op: Logical operator used to combine multiple conditions.
497
+ Supported values are ``"AND"`` and ``"OR"``. Defaults to
498
+ ``"AND"``.
499
+ :type logical_op: str
500
+
501
+ :return: A tuple containing the generated WHERE clause string and a
502
+ dictionary of parameter values. Returns an empty clause and
503
+ parameter dictionary when no conditions are provided or the logical
504
+ operator is invalid.
505
+ :rtype: tuple[str, dict]
506
+
507
+ :raises ValueError: If a condition specifies an unsupported SQL operator.
508
+ """
509
+
510
+ if not conditions:
511
+ return "", {}
512
+
513
+ if logical_op.lower not in ['and','or']:
514
+ return "", {}
515
+
516
+ ALLOWED_OPERATORS = {"=", "!=", ">", "<", ">=", "<=", "LIKE", "IN"}
517
+
518
+ clauses = []
519
+ params = {}
520
+
521
+ for i, (col_name, value) in enumerate(conditions.items()):
522
+ op = "="
523
+ val = value
524
+
525
+ if isinstance(value, tuple) and len(value) == 2:
526
+ op, val = value
527
+ op = str(op).upper()
528
+ if op not in ALLOWED_OPERATORS:
529
+ raise ValueError(f"Security Alert: Unsupported SQL operator '{op}'")
530
+
531
+
532
+ placeholder = f"{col_name}_{i}"
533
+
534
+ clauses.append(f"{col_name} {op} :{placeholder}")
535
+
536
+ params[placeholder] = val
537
+
538
+ where_string = f" {logical_op.upper()} ".join(clauses)
539
+ return where_string, params
540
+
541
+
542
+
543
+ def get_table_columns_(table_name: str, database_name: str, db_config: dict) -> list:
544
+ try:
545
+ engine = _get_engine(_set_connection_string(database_name, db_config))
546
+ inspector = inspect(engine)
547
+
548
+ columns = inspector.get_columns(table_name)
549
+
550
+ return True, [col['name'] for col in columns]
551
+
552
+ except Exception as e:
553
+ return False, f"Failed to fetch columns for '{table_name}': {e}"
@@ -0,0 +1,67 @@
1
+ import sys
2
+ import signal
3
+ import threading
4
+ import asyncio
5
+ from datetime import datetime
6
+
7
+ def setup_global_error_handler(logger_instance, loop: asyncio.AbstractEventLoop | None = None) -> None:
8
+ """
9
+ Configures global handlers for signals, uncaught exceptions, and thread
10
+ exceptions to ensure that process errors are logged and the logger is
11
+ properly flushed before shutdown.
12
+
13
+ :param logger_instance: Logger instance used to record errors and shutdown
14
+ events.
15
+ :type logger_instance: Any
16
+ :param loop: Asyncio event loop used to schedule logger shutdown and signal
17
+ handling. Defaults to the current event loop.
18
+ :type loop: asyncio.AbstractEventLoop | None
19
+
20
+ :return: None.
21
+ :rtype: None
22
+ """
23
+ loop = loop or asyncio.get_event_loop()
24
+
25
+ def _flush_and_stop(details: str):
26
+ async def _do():
27
+ logger_instance.write(
28
+ event={'process': 'System Interruption', 'type': 'User/System Kill',
29
+ 'parameters': 'Signal received', 'thread_id': None},
30
+ scenario='error', details=details,
31
+ )
32
+ await logger_instance.shutdown() # the piece that was never called
33
+ loop.stop()
34
+ asyncio.ensure_future(_do(), loop=loop)
35
+
36
+ def signal_handler():
37
+ _flush_and_stop("Signal received. Flushing queue before exit.")
38
+
39
+ for sig in (signal.SIGINT, signal.SIGTERM):
40
+ try:
41
+ loop.add_signal_handler(sig, signal_handler) # POSIX: safe, runs on the loop
42
+ except NotImplementedError:
43
+ signal.signal(sig, lambda s, f: signal_handler()) # Windows fallback
44
+
45
+ def exception_handler(exc_type, exc_value, exc_traceback):
46
+ if issubclass(exc_type, KeyboardInterrupt):
47
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
48
+ return
49
+ logger_instance.write(
50
+ event={'process': 'Fatal Crash', 'type': 'Unhandled Exception',
51
+ 'parameters': f"Type: {exc_type.__name__}", 'thread_id': None},
52
+ scenario='error', details=f"CRASH LOGIC: {exc_value}",
53
+ local_start_time=datetime.now(),
54
+ )
55
+ asyncio.ensure_future(logger_instance.shutdown(), loop=loop)
56
+ sys.__excepthook__(exc_type, exc_value, exc_traceback)
57
+
58
+ def thread_exception_handler(args: threading.ExceptHookArgs):
59
+ logger_instance.write(
60
+ event={'process': f"Thread Crash [{args.thread.name}]",
61
+ 'type': 'Unhandled Thread Exception',
62
+ 'parameters': f"Type: {args.exc_type.__name__}", 'thread_id': None},
63
+ scenario='error', details=str(args.exc_value),
64
+ )
65
+
66
+ sys.excepthook = exception_handler
67
+ threading.excepthook = thread_exception_handler # <-- previously silent