omdnotificationforwarder 4.6__tar.gz → 4.7__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.
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/.gitignore +1 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/PKG-INFO +55 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/README.md +54 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/bin/notificationforwarder +1 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/pyproject.toml +1 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/baseclass.py +70 -35
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/example/forwarder.py +2 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/json/logger.py +13 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/nostr/forwarder.py +2 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/rabbitmq/forwarder.py +3 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/spool.py +17 -7
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/text/logger.py +44 -7
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/webhook/forwarder.py +2 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/TESTS.md +11 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_builtin_plugins_focus.py +20 -2
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_classes.py +2 -2
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_contracts_forwarders.py +7 -7
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_delivery_resilience.py +4 -3
- omdnotificationforwarder-4.7/tests/test_lint_gate.py +80 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_loggers.py +16 -1
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_nostr.py +2 -2
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_resilience_spool.py +676 -22
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/component_loader.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/email/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/email/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/example/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/json/__init__.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/naemonlog/reporter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/nostr/__init__.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/nostr/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/runtime_config.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/runtime_flow.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/syslog/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/syslog/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/telegram/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/text/__init__.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/util.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_alertmanager.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_contracts_formatters.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_contracts_reporters.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_discard.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_formatter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_integration_cli.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_loading_resolution.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_logger.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_oauth2webhook.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_orchestration_flow.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_package.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_paths.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_reporter.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_runtime_foundation.py +0 -0
- {omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: omdnotificationforwarder
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.7
|
|
4
4
|
Summary: A framework for notification scripts for OMD
|
|
5
5
|
Project-URL: Homepage, https://github.com/lausser/noteventificationforhandlerwarder
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/lausser/noteventificationforhandlerwarder/issues
|
|
@@ -490,6 +490,46 @@ For broader confidence, run the full suite:
|
|
|
490
490
|
pytest
|
|
491
491
|
```
|
|
492
492
|
|
|
493
|
+
### Focused lint gate
|
|
494
|
+
|
|
495
|
+
`notificationforwarder/.flake8` selects `E9,E722,F63,F7,F82` and sets
|
|
496
|
+
`max-line-length = 120`. The check fails for undefined names, syntax errors,
|
|
497
|
+
other selected structural errors, and bare `except` handlers.
|
|
498
|
+
Undefined-name detection (F821) stays enabled: it is not suppressed globally,
|
|
499
|
+
per file, or with inline `noqa`. Forwarders whose `logger` is injected by the
|
|
500
|
+
component loader bind `logger = None`; they do not suppress F821.
|
|
501
|
+
|
|
502
|
+
CI runs this gate on Python 3.12 with flake8 7.5.0. The `test` extra lists
|
|
503
|
+
`flake8` without a pin; Python 3.9 and 3.10 cannot install flake8 7.5.0
|
|
504
|
+
(that release requires Python >=3.11), which is why the gate job is pinned
|
|
505
|
+
separately.
|
|
506
|
+
|
|
507
|
+
Run the gate:
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
# from notificationforwarder/
|
|
511
|
+
flake8 --config .flake8 src/notificationforwarder --count --statistics
|
|
512
|
+
|
|
513
|
+
# from the repository root
|
|
514
|
+
flake8 --config notificationforwarder/.flake8 notificationforwarder/src/notificationforwarder --count --statistics
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Expect zero findings on a clean tree.
|
|
518
|
+
|
|
519
|
+
Full diagnostic report, from `notificationforwarder/`. This is not the gate:
|
|
520
|
+
codes it reports that the gate does not select are not certified fixed or
|
|
521
|
+
harmless; reviewers read this report for those findings (examples on the
|
|
522
|
+
current tree: E501, F401, E402):
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
flake8 --isolated src/notificationforwarder/ --max-line-length=120 --statistics --count
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`tests/test_lint_gate.py` is the committed rejection probe: it proves the gate
|
|
529
|
+
rejects an undefined name, fails closed on a missing config, and that the
|
|
530
|
+
`logger = None` bindings do not hide other F821 findings. It runs flake8 from
|
|
531
|
+
the same interpreter that has the `test` extra installed.
|
|
532
|
+
|
|
493
533
|
## Reporters
|
|
494
534
|
|
|
495
535
|
Like *forwarder* and *formatter*, a *reporter* is an instance of a *NotificationReporter* class defined in a file named *reporter.py*. There is one class coming with notificationforwarder, the *NaemonlogReporter*. It's purpose it to write a message to the Naemon logfile. When notificationforwarder is run as a standalone script (and not triggered as a notificationhandler by Naemon), the *NaemonlogReporter* can nevertheless leave a line in the Naemon log.
|
|
@@ -527,6 +567,20 @@ If the receiver rejects a spooled event for good (for example because of an inva
|
|
|
527
567
|
|
|
528
568
|
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
529
569
|
|
|
570
|
+
To monitor the size of the spool, use the line "spooling queue length". It is logged with the whole number of spooled events every time an event is spooled (level WARNING) and at the end of every flush (level INFO), so the value also goes down again while the spool drains. With the JSON logger, the number can be read with the regular expression `"message":"spooling queue length","queue_length":(\d+)`. The value is only logged when notificationforwarder runs, so it can be outdated when no notifications arrive. A check that does not depend on notifications can count the rows in the spool database directly:
|
|
571
|
+
|
|
572
|
+
```
|
|
573
|
+
sqlite3 $OMD_ROOT/var/tmp/notificationforwarder_<forwarder>_notifications.db "SELECT COUNT(*) FROM events_<forwarder>"
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`<forwarder>` is the value of `--forwarder`, followed by `_` and the value of `--forwardertag` if one is set (for example `remedy_TEST`).
|
|
577
|
+
|
|
578
|
+
### Correlating Spool Log Entries
|
|
579
|
+
|
|
580
|
+
When a failed delivery is spooled, its `forward failed` line identifies the spool row and includes the stored raw event and its summary, if available. During replay, the delete, stays-in-spool, discard, could-not-format and delete-trash lines likewise include the row id and raw event, plus a non-empty summary when one exists. Failed replays also include any submit error. Every expired row gets its own id and raw-event line alongside the aggregate expired count; an undecodable expired row is logged with its id and payload text.
|
|
581
|
+
|
|
582
|
+
The text logger includes these details in each message, for example `delete spooled event 1 summary <sum: disk full> raw event <{'description': 'disk full'}>`. The JSON logger exposes them in the `msg` object as `event_id`, `raw_event` (the full structured stored event), and `summary` when available. The move-to-end line links the old `event_id` to `new_event_id`; follow that link if an event receives a new spool id before deletion.
|
|
583
|
+
|
|
530
584
|
### Delivery Order
|
|
531
585
|
|
|
532
586
|
Spooled events are stored and re-submitted in the order in which they failed. As long as every delivery succeeds, the receiver gets the events in the order in which they were created. After delivery failures, this is not guaranteed. A newer event can reach the receiver before an older one, for example a RECOVERY before its PROBLEM:
|
|
@@ -469,6 +469,46 @@ For broader confidence, run the full suite:
|
|
|
469
469
|
pytest
|
|
470
470
|
```
|
|
471
471
|
|
|
472
|
+
### Focused lint gate
|
|
473
|
+
|
|
474
|
+
`notificationforwarder/.flake8` selects `E9,E722,F63,F7,F82` and sets
|
|
475
|
+
`max-line-length = 120`. The check fails for undefined names, syntax errors,
|
|
476
|
+
other selected structural errors, and bare `except` handlers.
|
|
477
|
+
Undefined-name detection (F821) stays enabled: it is not suppressed globally,
|
|
478
|
+
per file, or with inline `noqa`. Forwarders whose `logger` is injected by the
|
|
479
|
+
component loader bind `logger = None`; they do not suppress F821.
|
|
480
|
+
|
|
481
|
+
CI runs this gate on Python 3.12 with flake8 7.5.0. The `test` extra lists
|
|
482
|
+
`flake8` without a pin; Python 3.9 and 3.10 cannot install flake8 7.5.0
|
|
483
|
+
(that release requires Python >=3.11), which is why the gate job is pinned
|
|
484
|
+
separately.
|
|
485
|
+
|
|
486
|
+
Run the gate:
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
# from notificationforwarder/
|
|
490
|
+
flake8 --config .flake8 src/notificationforwarder --count --statistics
|
|
491
|
+
|
|
492
|
+
# from the repository root
|
|
493
|
+
flake8 --config notificationforwarder/.flake8 notificationforwarder/src/notificationforwarder --count --statistics
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Expect zero findings on a clean tree.
|
|
497
|
+
|
|
498
|
+
Full diagnostic report, from `notificationforwarder/`. This is not the gate:
|
|
499
|
+
codes it reports that the gate does not select are not certified fixed or
|
|
500
|
+
harmless; reviewers read this report for those findings (examples on the
|
|
501
|
+
current tree: E501, F401, E402):
|
|
502
|
+
|
|
503
|
+
```bash
|
|
504
|
+
flake8 --isolated src/notificationforwarder/ --max-line-length=120 --statistics --count
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
`tests/test_lint_gate.py` is the committed rejection probe: it proves the gate
|
|
508
|
+
rejects an undefined name, fails closed on a missing config, and that the
|
|
509
|
+
`logger = None` bindings do not hide other F821 findings. It runs flake8 from
|
|
510
|
+
the same interpreter that has the `test` extra installed.
|
|
511
|
+
|
|
472
512
|
## Reporters
|
|
473
513
|
|
|
474
514
|
Like *forwarder* and *formatter*, a *reporter* is an instance of a *NotificationReporter* class defined in a file named *reporter.py*. There is one class coming with notificationforwarder, the *NaemonlogReporter*. It's purpose it to write a message to the Naemon logfile. When notificationforwarder is run as a standalone script (and not triggered as a notificationhandler by Naemon), the *NaemonlogReporter* can nevertheless leave a line in the Naemon log.
|
|
@@ -506,6 +546,20 @@ If the receiver rejects a spooled event for good (for example because of an inva
|
|
|
506
546
|
|
|
507
547
|
This means that in a busy monitoring environment where notifications flow regularly, spooled events get drained naturally — each new notification triggers a flush attempt on its way through.
|
|
508
548
|
|
|
549
|
+
To monitor the size of the spool, use the line "spooling queue length". It is logged with the whole number of spooled events every time an event is spooled (level WARNING) and at the end of every flush (level INFO), so the value also goes down again while the spool drains. With the JSON logger, the number can be read with the regular expression `"message":"spooling queue length","queue_length":(\d+)`. The value is only logged when notificationforwarder runs, so it can be outdated when no notifications arrive. A check that does not depend on notifications can count the rows in the spool database directly:
|
|
550
|
+
|
|
551
|
+
```
|
|
552
|
+
sqlite3 $OMD_ROOT/var/tmp/notificationforwarder_<forwarder>_notifications.db "SELECT COUNT(*) FROM events_<forwarder>"
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
`<forwarder>` is the value of `--forwarder`, followed by `_` and the value of `--forwardertag` if one is set (for example `remedy_TEST`).
|
|
556
|
+
|
|
557
|
+
### Correlating Spool Log Entries
|
|
558
|
+
|
|
559
|
+
When a failed delivery is spooled, its `forward failed` line identifies the spool row and includes the stored raw event and its summary, if available. During replay, the delete, stays-in-spool, discard, could-not-format and delete-trash lines likewise include the row id and raw event, plus a non-empty summary when one exists. Failed replays also include any submit error. Every expired row gets its own id and raw-event line alongside the aggregate expired count; an undecodable expired row is logged with its id and payload text.
|
|
560
|
+
|
|
561
|
+
The text logger includes these details in each message, for example `delete spooled event 1 summary <sum: disk full> raw event <{'description': 'disk full'}>`. The JSON logger exposes them in the `msg` object as `event_id`, `raw_event` (the full structured stored event), and `summary` when available. The move-to-end line links the old `event_id` to `new_event_id`; follow that link if an event receives a new spool id before deletion.
|
|
562
|
+
|
|
509
563
|
### Delivery Order
|
|
510
564
|
|
|
511
565
|
Spooled events are stored and re-submitted in the order in which they failed. As long as every delivery succeeds, the receiver gets the events in the order in which they were created. After delivery failures, this is not guaranteed. A newer event can reach the receiver before an older one, for example a RECOVERY before its PROBLEM:
|
|
@@ -78,7 +78,7 @@ Example for an HTTP-based reporter:
|
|
|
78
78
|
help='Logger type: text (default) or json, or custom logger module',
|
|
79
79
|
default='text')
|
|
80
80
|
parser.add_argument('--version', action='version',
|
|
81
|
-
version=f'%(prog)s 4.
|
|
81
|
+
version=f'%(prog)s 4.7')
|
|
82
82
|
|
|
83
83
|
args = parser.parse_args()
|
|
84
84
|
if not hasattr(args, 'formatter'):
|
{omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/baseclass.py
RENAMED
|
@@ -269,13 +269,11 @@ class NotificationForwarder(object):
|
|
|
269
269
|
result = self.forward_formatted(formatted_event)
|
|
270
270
|
success, report_payload, error_message = apply_forward_result(result)
|
|
271
271
|
if not success and not formatted_event.is_heartbeat:
|
|
272
|
-
|
|
273
|
-
if
|
|
274
|
-
failure_context =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
'status': 'failed',
|
|
278
|
-
}
|
|
272
|
+
spool_success, event_id = self.spool(raw_event)
|
|
273
|
+
if spool_success:
|
|
274
|
+
failure_context = self.spool_event_context(event_id, formatted_event, raw_event)
|
|
275
|
+
failure_context['spooled'] = True
|
|
276
|
+
failure_context['status'] = 'failed'
|
|
279
277
|
if error_message:
|
|
280
278
|
failure_context['exception'] = error_message
|
|
281
279
|
logger.warning("forward failed", failure_context)
|
|
@@ -400,19 +398,38 @@ class NotificationForwarder(object):
|
|
|
400
398
|
|
|
401
399
|
def spool(self, raw_event):
|
|
402
400
|
try:
|
|
403
|
-
self.spool_store.enqueue(raw_event)
|
|
401
|
+
event_id = self.spool_store.enqueue(raw_event)
|
|
404
402
|
spooled_events = self.num_spooled_events()
|
|
405
403
|
logger.warning("spooling queue length", {
|
|
406
404
|
'queue_length': spooled_events
|
|
407
405
|
})
|
|
408
|
-
return True
|
|
406
|
+
return (True, event_id)
|
|
409
407
|
except Exception as e:
|
|
410
408
|
logger.critical("database error", {
|
|
411
409
|
'database_error': True,
|
|
412
410
|
'exception': e
|
|
413
411
|
})
|
|
414
412
|
logger.info("raw event details", {'raw_event': raw_event})
|
|
415
|
-
return False
|
|
413
|
+
return (False, None)
|
|
414
|
+
|
|
415
|
+
def spool_event_context(self, event_id, formatted_event, raw_event):
|
|
416
|
+
"""Build the log context for a line which names a spool entry.
|
|
417
|
+
|
|
418
|
+
The id and the decoded raw event are always included, so every line
|
|
419
|
+
answers by itself which event is behind it. The summary is only
|
|
420
|
+
included when a formatted event exists and has a non-empty summary,
|
|
421
|
+
otherwise the key is omitted. The raw event carries the content even
|
|
422
|
+
when no formatted event exists (formatter failure).
|
|
423
|
+
"""
|
|
424
|
+
context = {
|
|
425
|
+
'event_id': event_id,
|
|
426
|
+
'raw_event': raw_event,
|
|
427
|
+
}
|
|
428
|
+
if formatted_event is not None:
|
|
429
|
+
context['formatted_event'] = formatted_event
|
|
430
|
+
if getattr(formatted_event, 'summary', None):
|
|
431
|
+
context['summary'] = formatted_event.summary
|
|
432
|
+
return context
|
|
416
433
|
|
|
417
434
|
def acquire_lock_with_retry(self, lock_file, max_attempts=3, base_delay=0.1):
|
|
418
435
|
return acquire_lock_with_retry(lock_file, logger, max_attempts=max_attempts, base_delay=base_delay)
|
|
@@ -454,7 +471,8 @@ class NotificationForwarder(object):
|
|
|
454
471
|
if locked:
|
|
455
472
|
self.baseclass_logs_summary = True
|
|
456
473
|
try:
|
|
457
|
-
|
|
474
|
+
dropped_rows = self.spool_store.prune_expired(self.max_spool_minutes)
|
|
475
|
+
dropped = len(dropped_rows)
|
|
458
476
|
replay_attempted = 0
|
|
459
477
|
replayed = 0
|
|
460
478
|
stayed_in_spool = 0
|
|
@@ -464,6 +482,24 @@ class NotificationForwarder(object):
|
|
|
464
482
|
'spooled_count': dropped,
|
|
465
483
|
'action': 'dropped'
|
|
466
484
|
})
|
|
485
|
+
# one line per expired event, so the operator can see
|
|
486
|
+
# which events were thrown away. decoding happens
|
|
487
|
+
# outside the database write lock, wrapped per row,
|
|
488
|
+
# so an undecodable payload cannot abort the flush
|
|
489
|
+
for expired_id, expired_text in dropped_rows:
|
|
490
|
+
try:
|
|
491
|
+
expired_event = self.spool_store.decode(expired_text)
|
|
492
|
+
except Exception as e:
|
|
493
|
+
logger.critical("expired spool entry could not be decoded", {
|
|
494
|
+
'event_id': expired_id,
|
|
495
|
+
'payload_text': expired_text,
|
|
496
|
+
'action': 'expired_undecodable',
|
|
497
|
+
'exception': e,
|
|
498
|
+
})
|
|
499
|
+
continue
|
|
500
|
+
context = self.spool_event_context(expired_id, None, expired_event)
|
|
501
|
+
context['action'] = 'expired'
|
|
502
|
+
logger.info("expired spooled event", context)
|
|
467
503
|
# walk through the rows which existed when the flush started.
|
|
468
504
|
# rows spooled meanwhile by concurrent processes are left for the
|
|
469
505
|
# next flush and each row is tried at most once. the first failed
|
|
@@ -484,13 +520,13 @@ class NotificationForwarder(object):
|
|
|
484
520
|
for id, text in id_events:
|
|
485
521
|
last_id = id
|
|
486
522
|
raw_event = self.spool_store.decode(text)
|
|
487
|
-
|
|
523
|
+
# Keep the stored event intact for logging even if plugins mutate their input.
|
|
524
|
+
formatted_event = self.format_event(self.spool_store.decode(text))
|
|
488
525
|
if formatted_event and formatted_event.is_discarded:
|
|
489
526
|
deleted_trash += 1
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
})
|
|
527
|
+
context = self.spool_event_context(id, formatted_event, raw_event)
|
|
528
|
+
context['action'] = 'discard_during_replay'
|
|
529
|
+
logger.info("discard spooled event during replay", context)
|
|
494
530
|
self.spool_store.delete(id)
|
|
495
531
|
elif formatted_event:
|
|
496
532
|
replay_attempted += 1
|
|
@@ -503,35 +539,29 @@ class NotificationForwarder(object):
|
|
|
503
539
|
if success:
|
|
504
540
|
self.spool_store.delete(id)
|
|
505
541
|
replayed += 1
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
})
|
|
542
|
+
context = self.spool_event_context(id, formatted_event, raw_event)
|
|
543
|
+
context['spooled_count'] = 1
|
|
544
|
+
context['action'] = 'delete'
|
|
545
|
+
logger.info("delete spooled event", context)
|
|
511
546
|
else:
|
|
512
547
|
stayed_in_spool += 1
|
|
513
|
-
context =
|
|
514
|
-
'event_id': id,
|
|
515
|
-
'action': 'stays_in_spool'
|
|
516
|
-
}
|
|
548
|
+
context = self.spool_event_context(id, formatted_event, raw_event)
|
|
517
549
|
if error_message:
|
|
518
550
|
context['exception'] = error_message
|
|
551
|
+
context['action'] = 'stays_in_spool'
|
|
519
552
|
logger.critical("event stays in spool", context)
|
|
520
553
|
self.flush_stopped_at = id
|
|
521
554
|
break
|
|
522
555
|
else:
|
|
523
556
|
deleted_trash += 1
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
'action': 'could_not_format'
|
|
529
|
-
})
|
|
557
|
+
context = self.spool_event_context(id, None, raw_event)
|
|
558
|
+
context['spooled_count'] = 1
|
|
559
|
+
context['action'] = 'could_not_format'
|
|
560
|
+
logger.critical("could not format spooled event", context)
|
|
530
561
|
self.spool_store.delete(id)
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
})
|
|
562
|
+
context = self.spool_event_context(id, None, raw_event)
|
|
563
|
+
context['action'] = 'delete_trash'
|
|
564
|
+
logger.info("delete trash event", context)
|
|
535
565
|
if self.flush_stopped_at is not None:
|
|
536
566
|
# only the rows this flush was responsible for, not
|
|
537
567
|
# the ones spooled concurrently after the snapshot
|
|
@@ -548,6 +578,11 @@ class NotificationForwarder(object):
|
|
|
548
578
|
'dropped_count': dropped,
|
|
549
579
|
})
|
|
550
580
|
self.spool_store.commit()
|
|
581
|
+
# same line as in spool(), so a monitored queue length
|
|
582
|
+
# also goes down again while the spool drains
|
|
583
|
+
logger.info("spooling queue length", {
|
|
584
|
+
'queue_length': self.spool_store.count()
|
|
585
|
+
})
|
|
551
586
|
except Exception as e:
|
|
552
587
|
logger.critical("database flush+resubmit failed", {
|
|
553
588
|
'database_error': True,
|
|
@@ -89,7 +89,7 @@ class JsonLogger(NotificationLogger):
|
|
|
89
89
|
msg_data["exception"]["trace"] = ''.join(
|
|
90
90
|
traceback.format_exception(*context['exc_info'])
|
|
91
91
|
)
|
|
92
|
-
except:
|
|
92
|
+
except Exception:
|
|
93
93
|
pass
|
|
94
94
|
else:
|
|
95
95
|
msg_data["exception"] = str(exc)
|
|
@@ -139,6 +139,18 @@ class JsonLogger(NotificationLogger):
|
|
|
139
139
|
msg_data["reporter_class"] = rpt.__class__.__name__
|
|
140
140
|
msg_data["reporter_module"] = rpt.__module_file__
|
|
141
141
|
|
|
142
|
+
# Add the full decoded raw event as its own structured field, even
|
|
143
|
+
# when a formatted event is also present. This is the payload as it
|
|
144
|
+
# was stored in the spool and is not reduced to Naemon keys.
|
|
145
|
+
if 'raw_event' in context and context['raw_event'] is not None:
|
|
146
|
+
msg_data["raw_event"] = context['raw_event']
|
|
147
|
+
# The summary of the spooled event, only present when the formatted
|
|
148
|
+
# event exists and has a non-empty summary
|
|
149
|
+
if 'summary' in context and context['summary']:
|
|
150
|
+
msg_data["summary"] = context['summary']
|
|
151
|
+
if 'payload_text' in context:
|
|
152
|
+
msg_data["payload_text"] = context['payload_text']
|
|
153
|
+
|
|
142
154
|
# Add event data for errors
|
|
143
155
|
if 'event_data' in context:
|
|
144
156
|
msg_data["event_data"] = context['event_data']
|
|
@@ -3,6 +3,8 @@ import json
|
|
|
3
3
|
import logging
|
|
4
4
|
from notificationforwarder.baseclass import NotificationForwarder, NotificationFormatter, timeout
|
|
5
5
|
|
|
6
|
+
logger = None # Set by the component loader.
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
class RabbitmqForwarder(NotificationForwarder):
|
|
8
10
|
def __init__(self, opts):
|
|
@@ -50,7 +52,7 @@ class RabbitmqForwarder(NotificationForwarder):
|
|
|
50
52
|
logger.critical("rabbitmq connect failed with error {}".format(e))
|
|
51
53
|
return False
|
|
52
54
|
|
|
53
|
-
def disconnect(
|
|
55
|
+
def disconnect(self):
|
|
54
56
|
try:
|
|
55
57
|
self.connection.close()
|
|
56
58
|
except Exception as e:
|
{omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/src/notificationforwarder/spool.py
RENAMED
|
@@ -45,18 +45,28 @@ class SpoolStore:
|
|
|
45
45
|
sql_insert = "INSERT INTO " + self.table_name + "(payload) VALUES (?)"
|
|
46
46
|
with self._lock:
|
|
47
47
|
self.cursor.execute(sql_insert, (json.dumps(raw_event),))
|
|
48
|
+
# capture the lastrowid inside the lock, a later INSERT on the
|
|
49
|
+
# shared cursor by another thread would overwrite it
|
|
50
|
+
event_id = self.cursor.lastrowid
|
|
48
51
|
self.connection.commit()
|
|
52
|
+
return event_id
|
|
49
53
|
|
|
50
54
|
def prune_expired(self, max_spool_minutes):
|
|
51
55
|
outdated = int(time.time() - 60 * max_spool_minutes)
|
|
52
|
-
|
|
56
|
+
sql_expire = "WHERE CAST(STRFTIME('%s', timestamp) AS INTEGER) < ?"
|
|
53
57
|
with self._lock:
|
|
54
|
-
self.cursor.execute(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
self.cursor.execute("BEGIN IMMEDIATE")
|
|
59
|
+
try:
|
|
60
|
+
# The transaction prevents another connection from changing
|
|
61
|
+
# the rows between the selection and deletion.
|
|
62
|
+
self.cursor.execute("SELECT id, payload FROM " + self.table_name + " " + sql_expire, (outdated,))
|
|
63
|
+
expired = self.cursor.fetchall()
|
|
64
|
+
self.cursor.execute("DELETE FROM " + self.table_name + " " + sql_expire, (outdated,))
|
|
65
|
+
self.connection.commit()
|
|
66
|
+
return expired
|
|
67
|
+
except Exception:
|
|
68
|
+
self.connection.rollback()
|
|
69
|
+
raise
|
|
60
70
|
|
|
61
71
|
def max_id(self):
|
|
62
72
|
with self._lock:
|
|
@@ -36,6 +36,24 @@ class TextLogger(NotificationLogger):
|
|
|
36
36
|
else:
|
|
37
37
|
log_func(full_message)
|
|
38
38
|
|
|
39
|
+
def _spool_event_line(self, lead, context):
|
|
40
|
+
"""Render a line which names a spool entry.
|
|
41
|
+
|
|
42
|
+
The lead already contains the event id. The summary is appended when
|
|
43
|
+
the context carries one, the raw event is the payload stored in the
|
|
44
|
+
spool, and the submit error ends the line when it exists."""
|
|
45
|
+
description = [lead]
|
|
46
|
+
if context.get('summary'):
|
|
47
|
+
description.append(f"summary <{context['summary']}>")
|
|
48
|
+
if 'raw_event' in context:
|
|
49
|
+
description.append(f"raw event <{context['raw_event']}>")
|
|
50
|
+
exception = context.get('exception')
|
|
51
|
+
if exception:
|
|
52
|
+
description.append(f"with exception <{exception}>")
|
|
53
|
+
if 'payload_text' in context:
|
|
54
|
+
description.append(f"payload <{context['payload_text']}>")
|
|
55
|
+
return " ".join(description)
|
|
56
|
+
|
|
39
57
|
def _build_message(self, message, context):
|
|
40
58
|
"""
|
|
41
59
|
Build text log message from structured context
|
|
@@ -70,6 +88,12 @@ class TextLogger(NotificationLogger):
|
|
|
70
88
|
if 'raw_event' in context and message.startswith("raw event"):
|
|
71
89
|
parts.append(f"caused error {context.get('exception', 'unknown')}")
|
|
72
90
|
|
|
91
|
+
# The spool-time line for a failed delivery which was spooled:
|
|
92
|
+
# names the spool row id, the stored raw event and the summary
|
|
93
|
+
if message == "forward failed" and 'event_id' in context and 'raw_event' in context:
|
|
94
|
+
return self._spool_event_line(
|
|
95
|
+
f"forward failed, spool id {context['event_id']}", context)
|
|
96
|
+
|
|
73
97
|
# Add formatter info for formatting errors
|
|
74
98
|
if 'event_data' in context and 'formatter_instance' in context:
|
|
75
99
|
fmt = context['formatter_instance']
|
|
@@ -98,10 +122,13 @@ class TextLogger(NotificationLogger):
|
|
|
98
122
|
return (f"event {context.get('event_id', '')} was rejected while the receiver accepts "
|
|
99
123
|
f"other events and moved to the end of the spool as {context.get('new_event_id', '')}")
|
|
100
124
|
|
|
101
|
-
# Handle
|
|
102
|
-
|
|
125
|
+
# Handle action-typed spool operations. The counting operations carry
|
|
126
|
+
# a spooled_count, the per-event lines describe one spooled row each
|
|
127
|
+
# with id, summary (when one exists) and raw event.
|
|
128
|
+
if 'action' in context:
|
|
103
129
|
action = context['action']
|
|
104
|
-
count = context
|
|
130
|
+
count = context.get('spooled_count')
|
|
131
|
+
event_id = context.get('event_id', '')
|
|
105
132
|
if action == 'resend':
|
|
106
133
|
return f"there are {count} spooled events to be re-sent"
|
|
107
134
|
elif action == 'dropped':
|
|
@@ -109,13 +136,23 @@ class TextLogger(NotificationLogger):
|
|
|
109
136
|
elif action == 'could_not_submit':
|
|
110
137
|
return f"{count} spooled events could not be submitted"
|
|
111
138
|
elif action == 'delete':
|
|
112
|
-
return f"delete spooled event {
|
|
139
|
+
return self._spool_event_line(f"delete spooled event {event_id}", context)
|
|
113
140
|
elif action == 'stays_in_spool':
|
|
114
|
-
return f"event {
|
|
141
|
+
return self._spool_event_line(f"event {event_id} stays in spool", context)
|
|
115
142
|
elif action == 'delete_trash':
|
|
116
|
-
return f"delete trash event {
|
|
143
|
+
return self._spool_event_line(f"delete trash event {event_id}", context)
|
|
144
|
+
elif action == 'discard_during_replay':
|
|
145
|
+
return self._spool_event_line(
|
|
146
|
+
f"discard spooled event during replay {event_id}", context)
|
|
147
|
+
elif action == 'expired':
|
|
148
|
+
return self._spool_event_line(
|
|
149
|
+
f"expired spooled event {event_id}", context)
|
|
150
|
+
elif action == 'expired_undecodable':
|
|
151
|
+
return (f"expired spool entry {event_id} could not be decoded, "
|
|
152
|
+
f"payload <{context.get('payload_text', '')}> with exception <{context.get('exception', '')}>")
|
|
117
153
|
elif action == 'could_not_format':
|
|
118
|
-
return f"could not format spooled {context.get('raw_event', '')}.
|
|
154
|
+
return (f"could not format spooled {context.get('raw_event', '')}. "
|
|
155
|
+
f"sorry, but i will delete this garbage with id {event_id}")
|
|
119
156
|
|
|
120
157
|
# Add queue length warnings
|
|
121
158
|
if 'queue_length' in context:
|
|
@@ -4,6 +4,8 @@ requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.
|
|
|
4
4
|
import os
|
|
5
5
|
from notificationforwarder.baseclass import NotificationForwarder, timeout
|
|
6
6
|
|
|
7
|
+
logger = None # Set by the component loader.
|
|
8
|
+
|
|
7
9
|
class WebhookForwarder(NotificationForwarder):
|
|
8
10
|
def __init__(self, opts):
|
|
9
11
|
super(self.__class__, self).__init__(opts)
|
|
@@ -36,6 +36,7 @@ This document describes every test file, test case, and the code paths they cove
|
|
|
36
36
|
- [test_oauth2webhook.py](#test_oauth2webhookpy)
|
|
37
37
|
- [test_reporter.py](#test_reporterpy)
|
|
38
38
|
- [test_logger.py](#test_loggerpy)
|
|
39
|
+
- [test_lint_gate.py](#test_lint_gatepy)
|
|
39
40
|
|
|
40
41
|
---
|
|
41
42
|
|
|
@@ -193,6 +194,16 @@ Spooled events 0, 1, 2 (ids 1, 2, 3) are replayed through a mocked `submit()`; t
|
|
|
193
194
|
| `test_no_move_when_event_is_already_last` | The rejected event is the only one → stays with id 1, no move log | Moving the last event would change nothing. |
|
|
194
195
|
| `test_moved_event_still_expires` | Event 0 spooled 4 minutes ago is moved, then `prune_expired(3)` drops it | Retention is measured from the original spool time. |
|
|
195
196
|
|
|
197
|
+
### class TestQueueLengthAfterFlush
|
|
198
|
+
|
|
199
|
+
| Test | What it verifies | Intention |
|
|
200
|
+
|------|------------------|-----------|
|
|
201
|
+
| `test_queue_length_zero_after_drain` | A flush that delivers all three spooled events logs info "spooling queue length" with `queue_length` 0 | A monitored queue length goes back down once the spool drained. |
|
|
202
|
+
| `test_queue_length_after_stopped_flush` | A flush that stops at the first failure logs `queue_length` 3 | The value is the remaining spool, also after a stopped flush. |
|
|
203
|
+
| `test_queue_length_includes_concurrently_spooled_events` | Another connection spools an event while the flush delivers all three → `queue_length` 1 | The value is the whole spool, not only the flush's snapshot. |
|
|
204
|
+
| `test_queue_length_not_logged_without_lock` | Flush lock not acquired → no "spooling queue length" line | Only a flush that ran reports a value. |
|
|
205
|
+
| `test_queue_length_json_format` | With the JSON logger, the regex `"message":"spooling queue length","queue_length":(\d+)` reads 0 after the drain | The documented monitoring regex works on real log output. |
|
|
206
|
+
|
|
196
207
|
### class TestSpoolStore
|
|
197
208
|
|
|
198
209
|
Low-level `SpoolStore` operations against a real SQLite database.
|
{omdnotificationforwarder-4.6 → omdnotificationforwarder-4.7}/tests/test_builtin_plugins_focus.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import importlib.util
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import sys
|
|
6
7
|
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import Mock
|
|
7
9
|
|
|
8
10
|
import pytest
|
|
9
11
|
|
|
@@ -58,6 +60,22 @@ def _formatted_event(eventopts):
|
|
|
58
60
|
return FormattedEvent(dict(eventopts))
|
|
59
61
|
|
|
60
62
|
|
|
63
|
+
def test_rabbitmq_disconnect_closes_connection_without_broker(monkeypatch):
|
|
64
|
+
monkeypatch.setitem(sys.modules, "pika", SimpleNamespace())
|
|
65
|
+
path = os.path.join(OMD_ROOT, "..", "src", "notificationforwarder", "rabbitmq", "forwarder.py")
|
|
66
|
+
spec = importlib.util.spec_from_file_location("rabbitmq_disconnect_test", path)
|
|
67
|
+
module = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(module)
|
|
69
|
+
connection = Mock()
|
|
70
|
+
runner = SimpleNamespace(connection=connection)
|
|
71
|
+
|
|
72
|
+
module.RabbitmqForwarder.disconnect(runner)
|
|
73
|
+
connection.close.assert_called_once_with()
|
|
74
|
+
connection.close.side_effect = RuntimeError("close failed")
|
|
75
|
+
module.RabbitmqForwarder.disconnect(runner)
|
|
76
|
+
assert connection.close.call_count == 2
|
|
77
|
+
|
|
78
|
+
|
|
61
79
|
def test_builtin_formatters_render_expected_payloads(setup):
|
|
62
80
|
cases = [
|
|
63
81
|
(
|
|
@@ -186,7 +204,7 @@ def test_webhook_forward_failure_spools_and_handles_spool_errors(setup, monkeypa
|
|
|
186
204
|
)
|
|
187
205
|
|
|
188
206
|
spooled = []
|
|
189
|
-
monkeypatch.setattr(forwarder, "spool", lambda raw_event: spooled.append(raw_event) or True)
|
|
207
|
+
monkeypatch.setattr(forwarder, "spool", lambda raw_event: spooled.append(raw_event) or (True, 1))
|
|
190
208
|
forwarder.forward({"description": "needs retry"})
|
|
191
209
|
assert spooled and spooled[0]["description"] == "needs retry"
|
|
192
210
|
|
|
@@ -200,7 +218,7 @@ def test_webhook_forward_failure_spools_and_handles_spool_errors(setup, monkeypa
|
|
|
200
218
|
"post",
|
|
201
219
|
lambda *args, **kwargs: SimpleNamespace(status_code=500, text="nope", reason="bad"),
|
|
202
220
|
)
|
|
203
|
-
monkeypatch.setattr(forwarder2, "spool", lambda raw_event: False)
|
|
221
|
+
monkeypatch.setattr(forwarder2, "spool", lambda raw_event: (False, None))
|
|
204
222
|
forwarder2.forward({"description": "cannot persist"})
|
|
205
223
|
|
|
206
224
|
|
|
@@ -150,7 +150,7 @@ def test_example_forwarder_forward_timeout(setup):
|
|
|
150
150
|
log = open(get_logfile(example)).read()
|
|
151
151
|
# this is the global log, written by the baseclass
|
|
152
152
|
assert "submit ran into a timeout" in log
|
|
153
|
-
assert "
|
|
153
|
+
assert "forward failed, spool id 1 summary <sum: halo i bims 1 alarm vong naemon her>" in log
|
|
154
154
|
assert "WARNING - spooling queue length is 1" in log
|
|
155
155
|
eventopts = {
|
|
156
156
|
"description": "halo i bim au 1 alarm vong naemon her",
|
|
@@ -159,7 +159,7 @@ def test_example_forwarder_forward_timeout(setup):
|
|
|
159
159
|
example = notificationforwarder.baseclass.new("example", None, "example", True, True, forwarderopts)
|
|
160
160
|
example.forward(eventopts)
|
|
161
161
|
log = open(get_logfile(example)).read()
|
|
162
|
-
assert "
|
|
162
|
+
assert "forward failed, spool id 2 summary <sum: halo i bim au 1 alarm vong naemon her>" in log
|
|
163
163
|
assert "WARNING - spooling queue length is 2" in log
|
|
164
164
|
# now the last two events were spooled and are in the database
|
|
165
165
|
|