omdnotificationforwarder 4.4__tar.gz → 4.5__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.
Files changed (73) hide show
  1. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/PKG-INFO +158 -4
  2. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/README.md +156 -2
  3. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/bin/notificationforwarder +1 -1
  4. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/pyproject.toml +1 -1
  5. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/baseclass.py +68 -61
  6. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/spool.py +26 -6
  7. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/TESTS.md +2 -0
  8. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_resilience_spool.py +129 -1
  9. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_runtime_foundation.py +17 -0
  10. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/.gitignore +0 -0
  11. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/component_loader.py +0 -0
  12. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/email/formatter.py +0 -0
  13. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/email/forwarder.py +0 -0
  14. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/example/formatter.py +0 -0
  15. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/example/forwarder.py +0 -0
  16. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/json/__init__.py +0 -0
  17. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/json/logger.py +0 -0
  18. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/naemonlog/reporter.py +0 -0
  19. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/__init__.py +0 -0
  20. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/formatter.py +0 -0
  21. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/nostr/forwarder.py +0 -0
  22. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
  23. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
  24. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
  25. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
  26. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/runtime_config.py +0 -0
  27. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/runtime_flow.py +0 -0
  28. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/syslog/formatter.py +0 -0
  29. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/syslog/forwarder.py +0 -0
  30. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/telegram/forwarder.py +0 -0
  31. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/text/__init__.py +0 -0
  32. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/text/logger.py +0 -0
  33. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/util.py +0 -0
  34. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/src/notificationforwarder/webhook/forwarder.py +0 -0
  35. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
  36. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
  37. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  38. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
  39. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  40. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
  41. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
  42. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
  43. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
  44. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
  45. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
  46. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  47. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
  48. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  49. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
  50. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
  51. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
  52. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
  53. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
  54. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_alertmanager.py +0 -0
  55. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_builtin_plugins_focus.py +0 -0
  56. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_classes.py +0 -0
  57. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_contracts_formatters.py +0 -0
  58. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_contracts_forwarders.py +0 -0
  59. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_contracts_reporters.py +0 -0
  60. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_delivery_resilience.py +0 -0
  61. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_discard.py +0 -0
  62. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_formatter.py +0 -0
  63. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_integration_cli.py +0 -0
  64. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_loading_resolution.py +0 -0
  65. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_logger.py +0 -0
  66. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_loggers.py +0 -0
  67. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_nostr.py +0 -0
  68. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_oauth2webhook.py +0 -0
  69. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_orchestration_flow.py +0 -0
  70. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_package.py +0 -0
  71. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_paths.py +0 -0
  72. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_reporter.py +0 -0
  73. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.5}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: omdnotificationforwarder
3
- Version: 4.4
3
+ Version: 4.5
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
@@ -276,13 +276,18 @@ Install the optional Nostr support with `pip install notificationforwarder[nostr
276
276
  |nsec|bech32 private key for signing|required|
277
277
  |tags|extra Nostr tags to include|`monitoring` plus host/service/state tags|
278
278
 
279
- Example:
279
+ The recipient is identified by a `p` tag containing the recipient's public key (npub). The `p` tag must be present in the tags list — the forwarder will raise an error if no recipient is found.
280
+
281
+ #### Static recipient
282
+
283
+ If all notifications go to the same recipient, pass the `p` tag via `--forwarderopt tags`:
280
284
 
281
285
  ```bash
282
286
  $USER1$/notificationforwarder \
283
287
  --forwarder nostr \
284
288
  --forwarderopt relays='wss://relay.damus.io,wss://nostr-pub.wellorder.net' \
285
289
  --forwarderopt nsec='$NSEC$' \
290
+ --forwarderopt tags='[["p", "npub1xyz..."]]' \
286
291
  --formatter nostr \
287
292
  --eventopt HOSTNAME='$HOSTNAME$' \
288
293
  --eventopt SERVICEDESC='$SERVICEDESC$' \
@@ -290,7 +295,55 @@ $USER1$/notificationforwarder \
290
295
  --eventopt SERVICEOUTPUT='$SERVICEOUTPUT$'
291
296
  ```
292
297
 
293
- The recipient `p` tag can be provided through the `tags` forwarder option or custom formatter output. The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
298
+ #### Per-contact recipient
299
+
300
+ In a real monitoring setup, different contacts may have different Nostr public keys. Use a custom contact variable (e.g., `$CONTACTNOSTRNPUB$`) and pass it as an eventopt. The formatter then adds it as the `p` tag.
301
+
302
+ **Contact definition:**
303
+
304
+ ```
305
+ define contact {
306
+ contact_name admin-team
307
+ ...
308
+ _NOSTRNPUB npub1xyz...
309
+ }
310
+ ```
311
+
312
+ **Command definition:**
313
+
314
+ ```
315
+ define command {
316
+ command_name notify-service-nostr
317
+ command_line $USER1$/notificationforwarder \
318
+ --forwarder nostr \
319
+ --forwarderopt relays='wss://relay.damus.io' \
320
+ --forwarderopt nsec='$NSEC$' \
321
+ --formatter nostr_custom \
322
+ --eventopt HOSTNAME='$HOSTNAME$' \
323
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
324
+ --eventopt SERVICESTATE='$SERVICESTATE$' \
325
+ --eventopt SERVICEOUTPUT='$SERVICEOUTPUT$' \
326
+ --eventopt NOSTR_PUBKEY='$_CONTACTNOSTRNPUB$' \
327
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
328
+ }
329
+ ```
330
+
331
+ **Custom formatter** in `~/local/lib/python/notificationforwarder/nostr_custom/formatter.py`:
332
+
333
+ ```python
334
+ from notificationforwarder.nostr.formatter import NostrFormatter
335
+
336
+ class NostrCustomFormatter(NostrFormatter):
337
+ def format_event(self, event):
338
+ super().format_event(event)
339
+ pubkey = event.eventopts.get("NOSTR_PUBKEY", "")
340
+ if pubkey:
341
+ event.payload["tags"].append(["p", pubkey])
342
+ ```
343
+
344
+ The `p` tag is appended to the tags list that the default Nostr formatter already builds with monitoring tags. The forwarder picks it up and uses it as the recipient for the encrypted DM.
345
+
346
+ The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
294
347
 
295
348
  ## Loggers
296
349
 
@@ -463,3 +516,104 @@ define command{
463
516
  >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
464
517
  }
465
518
  ```
519
+
520
+ ## Spool Flushing
521
+
522
+ When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
523
+
524
+ The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool. Events that still fail stay in the queue for the next attempt.
525
+
526
+ 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.
527
+
528
+ ### Probe Method
529
+
530
+ Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
531
+
532
+ The framework uses `probe()` as a pre-check with three possible scenarios:
533
+
534
+ |Forwarder has probe()?|probe() result|What happens|Why|
535
+ |---|---|---|---|
536
+ |No|—|flush proceeds|No way to check availability, must try|
537
+ |Yes|True|flush proceeds|Receiver is confirmed reachable|
538
+ |Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
539
+
540
+ Without `probe()`, every flush attempt would call `submit()` for each spooled event even when the receiver is known to be down — wasting time and producing noisy error logs. With `probe()`, the framework can short-circuit: if the receiver is unreachable, the flush is skipped entirely and spooled events wait in the database until the next call.
541
+
542
+ Example implementation:
543
+
544
+ ```python
545
+ def probe(self):
546
+ r = requests.head(self.url)
547
+ return r.status_code == 200
548
+ ```
549
+
550
+ ### Heartbeat Events
551
+
552
+ Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
553
+
554
+ 1. **They skip spooling on failure.** If a heartbeat event cannot be delivered, it is NOT saved to the spool database. This prevents periodic check-in events from accumulating in the retry queue.
555
+
556
+ 2. **They guarantee regular flush attempts.** While every event triggers the flush phase, heartbeat events ensure this happens at regular intervals — but only when scheduled. A cronjob or Nagios service using notificationforwarder as its check command provides this scheduling, creating a steady pulse of heartbeat events. Without such a schedule, heartbeats don't exist and spooled events could sit in the queue for hours waiting for the next real notification to trigger a flush.
557
+
558
+ Example formatter that sets the heartbeat flag:
559
+
560
+ ```python
561
+ class MyFormatter(NotificationFormatter):
562
+ def format_event(self, event):
563
+ notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
564
+ event.is_heartbeat = notification_type == "HEARTBEAT"
565
+
566
+ event.payload = {
567
+ "hostname": event.eventopts.get("HOSTNAME", ""),
568
+ "heartbeat": event.is_heartbeat,
569
+ }
570
+ event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
571
+ ```
572
+
573
+ ### Scheduling Heartbeats in Nagios/Naemon
574
+
575
+ Since notificationforwarder always exits with code 0, it can be used as a Nagios check command to schedule periodic heartbeats. The service will always be in OK status — it's using Nagios purely as a scheduling mechanism.
576
+
577
+ **Command definition:**
578
+
579
+ ```
580
+ define command {
581
+ command_name notificationforwarder_heartbeat
582
+ command_line $USER1$/notificationforwarder \
583
+ --forwarder webhook \
584
+ --forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
585
+ --formatter myformatter \
586
+ --eventopt HOSTNAME='$HOSTNAME$' \
587
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
588
+ --eventopt NOTIFICATIONTYPE=HEARTBEAT \
589
+ --eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
590
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
591
+ }
592
+ ```
593
+
594
+ **Service definition:**
595
+
596
+ ```
597
+ define service {
598
+ host_name monitoring-server
599
+ service_description notificationforwarder-heartbeat
600
+ check_command notificationforwarder_heartbeat
601
+ max_check_attempts 1
602
+ check_interval 5
603
+ retry_interval 1
604
+ notification_interval 0
605
+ notification_options n
606
+ notification_period 24x7
607
+ }
608
+ ```
609
+
610
+ The key settings:
611
+ - `check_interval 5` — runs the heartbeat every 5 minutes
612
+ - `notification_interval 0` — no Nagios-level notifications
613
+ - `notification_options n` — don't send notifications on state changes
614
+
615
+ The heartbeat goes through the normal notificationforwarder flow: the formatter sets `is_heartbeat = True`, the flush phase checks for spooled events (and calls `probe()` if available), and the heartbeat itself is submitted to the receiver. If the receiver accepts it, the receiving system knows the monitoring infrastructure is alive (dead-man switch). If the receiver is down, the heartbeat is simply dropped — it won't add to the spool queue.
616
+
617
+ The two use cases for periodic heartbeats:
618
+ - **Dead-man switch**: The receiving system (e.g., ServiceNow) gets a regular "I'm alive" signal. If heartbeats stop, it knows the monitoring system is down.
619
+ - **Spool draining**: Even when nothing breaks, the heartbeat triggers flush attempts at regular intervals, so spooled events are delivered quickly once the receiver recovers — without waiting for the next real notification.
@@ -255,13 +255,18 @@ Install the optional Nostr support with `pip install notificationforwarder[nostr
255
255
  |nsec|bech32 private key for signing|required|
256
256
  |tags|extra Nostr tags to include|`monitoring` plus host/service/state tags|
257
257
 
258
- Example:
258
+ The recipient is identified by a `p` tag containing the recipient's public key (npub). The `p` tag must be present in the tags list — the forwarder will raise an error if no recipient is found.
259
+
260
+ #### Static recipient
261
+
262
+ If all notifications go to the same recipient, pass the `p` tag via `--forwarderopt tags`:
259
263
 
260
264
  ```bash
261
265
  $USER1$/notificationforwarder \
262
266
  --forwarder nostr \
263
267
  --forwarderopt relays='wss://relay.damus.io,wss://nostr-pub.wellorder.net' \
264
268
  --forwarderopt nsec='$NSEC$' \
269
+ --forwarderopt tags='[["p", "npub1xyz..."]]' \
265
270
  --formatter nostr \
266
271
  --eventopt HOSTNAME='$HOSTNAME$' \
267
272
  --eventopt SERVICEDESC='$SERVICEDESC$' \
@@ -269,7 +274,55 @@ $USER1$/notificationforwarder \
269
274
  --eventopt SERVICEOUTPUT='$SERVICEOUTPUT$'
270
275
  ```
271
276
 
272
- The recipient `p` tag can be provided through the `tags` forwarder option or custom formatter output. The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
277
+ #### Per-contact recipient
278
+
279
+ In a real monitoring setup, different contacts may have different Nostr public keys. Use a custom contact variable (e.g., `$CONTACTNOSTRNPUB$`) and pass it as an eventopt. The formatter then adds it as the `p` tag.
280
+
281
+ **Contact definition:**
282
+
283
+ ```
284
+ define contact {
285
+ contact_name admin-team
286
+ ...
287
+ _NOSTRNPUB npub1xyz...
288
+ }
289
+ ```
290
+
291
+ **Command definition:**
292
+
293
+ ```
294
+ define command {
295
+ command_name notify-service-nostr
296
+ command_line $USER1$/notificationforwarder \
297
+ --forwarder nostr \
298
+ --forwarderopt relays='wss://relay.damus.io' \
299
+ --forwarderopt nsec='$NSEC$' \
300
+ --formatter nostr_custom \
301
+ --eventopt HOSTNAME='$HOSTNAME$' \
302
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
303
+ --eventopt SERVICESTATE='$SERVICESTATE$' \
304
+ --eventopt SERVICEOUTPUT='$SERVICEOUTPUT$' \
305
+ --eventopt NOSTR_PUBKEY='$_CONTACTNOSTRNPUB$' \
306
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
307
+ }
308
+ ```
309
+
310
+ **Custom formatter** in `~/local/lib/python/notificationforwarder/nostr_custom/formatter.py`:
311
+
312
+ ```python
313
+ from notificationforwarder.nostr.formatter import NostrFormatter
314
+
315
+ class NostrCustomFormatter(NostrFormatter):
316
+ def format_event(self, event):
317
+ super().format_event(event)
318
+ pubkey = event.eventopts.get("NOSTR_PUBKEY", "")
319
+ if pubkey:
320
+ event.payload["tags"].append(["p", pubkey])
321
+ ```
322
+
323
+ The `p` tag is appended to the tags list that the default Nostr formatter already builds with monitoring tags. The forwarder picks it up and uses it as the recipient for the encrypted DM.
324
+
325
+ The message content is encrypted before relay publication, so it shows up as a DM in clients that support NIP-04 direct messages.
273
326
 
274
327
  ## Loggers
275
328
 
@@ -442,3 +495,104 @@ define command{
442
495
  >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
443
496
  }
444
497
  ```
498
+
499
+ ## Spool Flushing
500
+
501
+ When forwarding to a recipient fails, the event is saved in a local sqlite database. The framework automatically attempts to re-submit these spooled events on every subsequent call to notificationforwarder for the same forwarder.
502
+
503
+ The flush mechanism runs inside `forward_formatted()` for **every event** — not just heartbeats or special cases. Before submitting the current event, the framework checks if there are spooled events waiting. If there are, it attempts to flush them by calling `submit()` for each one. Events that are successfully submitted are deleted from the spool. Events that still fail stay in the queue for the next attempt.
504
+
505
+ 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.
506
+
507
+ ### Probe Method
508
+
509
+ Forwarders can implement an optional `probe()` method to optimize the flush behavior. The `probe()` method checks whether the receiving system is reachable *before* the framework attempts to flush spooled events via `submit()`.
510
+
511
+ The framework uses `probe()` as a pre-check with three possible scenarios:
512
+
513
+ |Forwarder has probe()?|probe() result|What happens|Why|
514
+ |---|---|---|---|
515
+ |No|—|flush proceeds|No way to check availability, must try|
516
+ |Yes|True|flush proceeds|Receiver is confirmed reachable|
517
+ |Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
518
+
519
+ Without `probe()`, every flush attempt would call `submit()` for each spooled event even when the receiver is known to be down — wasting time and producing noisy error logs. With `probe()`, the framework can short-circuit: if the receiver is unreachable, the flush is skipped entirely and spooled events wait in the database until the next call.
520
+
521
+ Example implementation:
522
+
523
+ ```python
524
+ def probe(self):
525
+ r = requests.head(self.url)
526
+ return r.status_code == 200
527
+ ```
528
+
529
+ ### Heartbeat Events
530
+
531
+ Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
532
+
533
+ 1. **They skip spooling on failure.** If a heartbeat event cannot be delivered, it is NOT saved to the spool database. This prevents periodic check-in events from accumulating in the retry queue.
534
+
535
+ 2. **They guarantee regular flush attempts.** While every event triggers the flush phase, heartbeat events ensure this happens at regular intervals — but only when scheduled. A cronjob or Nagios service using notificationforwarder as its check command provides this scheduling, creating a steady pulse of heartbeat events. Without such a schedule, heartbeats don't exist and spooled events could sit in the queue for hours waiting for the next real notification to trigger a flush.
536
+
537
+ Example formatter that sets the heartbeat flag:
538
+
539
+ ```python
540
+ class MyFormatter(NotificationFormatter):
541
+ def format_event(self, event):
542
+ notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
543
+ event.is_heartbeat = notification_type == "HEARTBEAT"
544
+
545
+ event.payload = {
546
+ "hostname": event.eventopts.get("HOSTNAME", ""),
547
+ "heartbeat": event.is_heartbeat,
548
+ }
549
+ event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
550
+ ```
551
+
552
+ ### Scheduling Heartbeats in Nagios/Naemon
553
+
554
+ Since notificationforwarder always exits with code 0, it can be used as a Nagios check command to schedule periodic heartbeats. The service will always be in OK status — it's using Nagios purely as a scheduling mechanism.
555
+
556
+ **Command definition:**
557
+
558
+ ```
559
+ define command {
560
+ command_name notificationforwarder_heartbeat
561
+ command_line $USER1$/notificationforwarder \
562
+ --forwarder webhook \
563
+ --forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
564
+ --formatter myformatter \
565
+ --eventopt HOSTNAME='$HOSTNAME$' \
566
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
567
+ --eventopt NOTIFICATIONTYPE=HEARTBEAT \
568
+ --eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
569
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
570
+ }
571
+ ```
572
+
573
+ **Service definition:**
574
+
575
+ ```
576
+ define service {
577
+ host_name monitoring-server
578
+ service_description notificationforwarder-heartbeat
579
+ check_command notificationforwarder_heartbeat
580
+ max_check_attempts 1
581
+ check_interval 5
582
+ retry_interval 1
583
+ notification_interval 0
584
+ notification_options n
585
+ notification_period 24x7
586
+ }
587
+ ```
588
+
589
+ The key settings:
590
+ - `check_interval 5` — runs the heartbeat every 5 minutes
591
+ - `notification_interval 0` — no Nagios-level notifications
592
+ - `notification_options n` — don't send notifications on state changes
593
+
594
+ The heartbeat goes through the normal notificationforwarder flow: the formatter sets `is_heartbeat = True`, the flush phase checks for spooled events (and calls `probe()` if available), and the heartbeat itself is submitted to the receiver. If the receiver accepts it, the receiving system knows the monitoring infrastructure is alive (dead-man switch). If the receiver is down, the heartbeat is simply dropped — it won't add to the spool queue.
595
+
596
+ The two use cases for periodic heartbeats:
597
+ - **Dead-man switch**: The receiving system (e.g., ServiceNow) gets a regular "I'm alive" signal. If heartbeats stop, it knows the monitoring system is down.
598
+ - **Spool draining**: Even when nothing breaks, the heartbeat triggers flush attempts at regular intervals, so spooled events are delivered quickly once the receiver recovers — without waiting for the next real notification.
@@ -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.4')
81
+ version=f'%(prog)s 4.5')
82
82
 
83
83
  args = parser.parse_args()
84
84
  if not hasattr(args, 'formatter'):
@@ -21,7 +21,7 @@ packages = ["src/notificationforwarder"]
21
21
 
22
22
  [project]
23
23
  name = "omdnotificationforwarder"
24
- version = "4.4"
24
+ version = "4.5"
25
25
  authors = [
26
26
  { name="Gerhard Lausser", email="lausser@yahoo.com" },
27
27
  ]
@@ -427,75 +427,82 @@ class NotificationForwarder(object):
427
427
  'spooled_count': dropped,
428
428
  'action': 'dropped'
429
429
  })
430
- last_events_to_flush = 0
430
+ # walk through the rows which existed when the flush started.
431
+ # rows spooled meanwhile by concurrent processes are left for the
432
+ # next flush and each row is tried at most once, so the flush lock
433
+ # is not held forever while the destination keeps failing.
434
+ last_id = 0
435
+ snapshot_max_id = self.spool_store.max_id()
431
436
  while True:
432
- events_to_flush = self.num_spooled_events()
433
- if events_to_flush:
434
- logger.info("spooled events to be re-sent", {
435
- 'spooled_count': events_to_flush,
436
- 'action': 'resend'
437
- })
438
- else:
437
+ id_events = self.spool_store.fetch_batch(after_id=last_id, max_id=snapshot_max_id)
438
+ if not id_events:
439
439
  logger.debug("nothing left to flush", {})
440
440
  break
441
- if last_events_to_flush == events_to_flush:
442
- if events_to_flush != 0:
443
- logger.critical("spooled events could not be submitted", {
444
- 'spooled_count': last_events_to_flush,
445
- 'action': 'could_not_submit'
441
+ logger.info("spooled events to be re-sent", {
442
+ 'spooled_count': len(id_events),
443
+ 'action': 'resend'
444
+ })
445
+ progress = False
446
+ for id, text in id_events:
447
+ last_id = id
448
+ raw_event = self.spool_store.decode(text)
449
+ formatted_event = self.format_event(raw_event)
450
+ if formatted_event and formatted_event.is_discarded:
451
+ deleted_trash += 1
452
+ logger.info("discard spooled event during replay", {
453
+ 'event_id': id,
454
+ 'action': 'discard_during_replay'
446
455
  })
447
- break
448
- else:
449
- id_events = self.spool_store.fetch_batch()
450
- for id, text in id_events:
451
- raw_event = self.spool_store.decode(text)
452
- formatted_event = self.format_event(raw_event)
453
- if formatted_event and formatted_event.is_discarded:
454
- deleted_trash += 1
455
- logger.info("discard spooled event during replay", {
456
- 'event_id': id,
457
- 'action': 'discard_during_replay'
458
- })
456
+ self.spool_store.delete(id)
457
+ progress = True
458
+ elif formatted_event:
459
+ replay_attempted += 1
460
+ try:
461
+ result = self.submit(formatted_event)
462
+ success, _report_payload, error_message = apply_forward_result(result)
463
+ except Exception as e:
464
+ success = False
465
+ error_message = str(e)
466
+ if success:
459
467
  self.spool_store.delete(id)
460
- elif formatted_event:
461
- replay_attempted += 1
462
- try:
463
- result = self.submit(formatted_event)
464
- success, _report_payload, error_message = apply_forward_result(result)
465
- except Exception as e:
466
- success = False
467
- error_message = str(e)
468
- if success:
469
- self.spool_store.delete(id)
470
- replayed += 1
471
- logger.info("delete spooled event", {
472
- 'spooled_count': 1,
473
- 'event_id': id,
474
- 'action': 'delete'
475
- })
476
- else:
477
- stayed_in_spool += 1
478
- context = {
479
- 'event_id': id,
480
- 'action': 'stays_in_spool'
481
- }
482
- if error_message:
483
- context['exception'] = error_message
484
- logger.critical("event stays in spool", context)
485
- else:
486
- deleted_trash += 1
487
- logger.critical("could not format spooled event", {
488
- 'raw_event': raw_event,
489
- 'event_id': id,
468
+ progress = True
469
+ replayed += 1
470
+ logger.info("delete spooled event", {
490
471
  'spooled_count': 1,
491
- 'action': 'could_not_format'
492
- })
493
- self.spool_store.delete(id)
494
- logger.info("delete trash event", {
495
472
  'event_id': id,
496
- 'action': 'delete_trash'
473
+ 'action': 'delete'
497
474
  })
498
- last_events_to_flush = events_to_flush
475
+ else:
476
+ stayed_in_spool += 1
477
+ context = {
478
+ 'event_id': id,
479
+ 'action': 'stays_in_spool'
480
+ }
481
+ if error_message:
482
+ context['exception'] = error_message
483
+ logger.critical("event stays in spool", context)
484
+ else:
485
+ deleted_trash += 1
486
+ logger.critical("could not format spooled event", {
487
+ 'raw_event': raw_event,
488
+ 'event_id': id,
489
+ 'spooled_count': 1,
490
+ 'action': 'could_not_format'
491
+ })
492
+ self.spool_store.delete(id)
493
+ progress = True
494
+ logger.info("delete trash event", {
495
+ 'event_id': id,
496
+ 'action': 'delete_trash'
497
+ })
498
+ if not progress:
499
+ # only the rows this flush was responsible for, not
500
+ # the ones spooled concurrently after the snapshot
501
+ logger.critical("spooled events could not be submitted", {
502
+ 'spooled_count': self.spool_store.count(max_id=snapshot_max_id),
503
+ 'action': 'could_not_submit'
504
+ })
505
+ break
499
506
  logger.info("spool replay summary", {
500
507
  'attempted': replay_attempted,
501
508
  'recovered_count': replayed,
@@ -31,9 +31,14 @@ class SpoolStore:
31
31
  self.cursor.execute(sql_create)
32
32
  self.connection.commit()
33
33
 
34
- def count(self):
34
+ def count(self, max_id=None):
35
+ sql_count = "SELECT COUNT(*) FROM " + self.table_name
36
+ params = []
37
+ if max_id is not None:
38
+ sql_count += " WHERE id <= ?"
39
+ params.append(max_id)
35
40
  with self._lock:
36
- self.cursor.execute("SELECT COUNT(*) FROM " + self.table_name)
41
+ self.cursor.execute(sql_count, params)
37
42
  return self.cursor.fetchone()[0]
38
43
 
39
44
  def enqueue(self, raw_event):
@@ -47,12 +52,27 @@ class SpoolStore:
47
52
  sql_delete = "DELETE FROM " + self.table_name + " WHERE CAST(STRFTIME('%s', timestamp) AS INTEGER) < ?"
48
53
  with self._lock:
49
54
  self.cursor.execute(sql_delete, (outdated,))
50
- return self.cursor.rowcount
55
+ dropped = self.cursor.rowcount
56
+ # commit immediately, otherwise the implicit transaction keeps
57
+ # the database write-locked for other processes during a flush
58
+ self.connection.commit()
59
+ return dropped
60
+
61
+ def max_id(self):
62
+ with self._lock:
63
+ self.cursor.execute("SELECT MAX(id) FROM " + self.table_name)
64
+ return self.cursor.fetchone()[0] or 0
51
65
 
52
- def fetch_batch(self, limit=10):
53
- sql_select = "SELECT id, payload FROM " + self.table_name + " ORDER BY id LIMIT ?"
66
+ def fetch_batch(self, limit=10, after_id=0, max_id=None):
67
+ sql_select = "SELECT id, payload FROM " + self.table_name + " WHERE id > ?"
68
+ params = [after_id]
69
+ if max_id is not None:
70
+ sql_select += " AND id <= ?"
71
+ params.append(max_id)
72
+ sql_select += " ORDER BY id LIMIT ?"
73
+ params.append(limit)
54
74
  with self._lock:
55
- self.cursor.execute(sql_select, (limit,))
75
+ self.cursor.execute(sql_select, params)
56
76
  return self.cursor.fetchall()
57
77
 
58
78
  def delete(self, event_id):
@@ -170,6 +170,7 @@ Integration tests for `load_forwarder()` using the `pythonpath/lib/python` test
170
170
  | `test_persistent_failure_keeps_row_and_terminates` | `submit()` always returning `False` → event stays in spool, stuck detection fires | Persistent failures are not silently dropped; the system logs and stops retrying. |
171
171
  | `test_spool_init_failure_returns_false` | `SpoolStore.enqueue` raising `sqlite3.OperationalError` → returns `False` with "database error" log | Database errors are caught and logged, not raised. |
172
172
  | `test_flush_beyond_batch_limit_stuck_detection` | 12 spooled events (exceeds batch limit of 10) with all submits failing → stuck detection, all events stay | The stuck detection logic handles batch boundaries correctly. |
173
+ | `test_flush_terminates_while_other_processes_spool` | All submits fail while a second connection spools a new event after each submit → flush stops after the first batch, events 0–9 submitted once each, all 22 rows kept, stuck log reports `spooled_count` 12 (snapshot rows, not the live queue) | A flush is bounded to the rows present at its start; concurrent spooling cannot keep it (and the flush lock) alive. |
173
174
  | `test_formatter_returns_none_during_replay_deletes_trash` | `format_event` returning `None` during replay → event deleted as trash | Un-formattable events are cleaned up, not retried forever. |
174
175
  | `test_summary_logging_resumes_after_flush` | `baseclass_logs_summary` is reset to `True` at flush start | The no_more_logging flag is reset so operators see summary logs after recovery. |
175
176
 
@@ -186,6 +187,7 @@ Low-level `SpoolStore` operations against a real SQLite database.
186
187
  | `test_fetch_batch_limit_zero` | `fetch_batch(limit=0)` returns `[]` | Zero limit is handled. |
187
188
  | `test_fetch_batch_limit_exceeds_total` | Limit exceeding total returns all rows | No error when limit is too high. |
188
189
  | `test_prune_expired_boundary` | `prune_expired(5)` deletes only events strictly older than 5 minutes | Boundary conditions are correct. |
190
+ | `test_prune_expired_does_not_hold_write_lock` | After `prune_expired()`, a second connection with a 0.1 s busy timeout can insert; `count(max_id=1)` counts only rows up to that id | Pruning commits immediately, so other processes can spool while a flush replays. |
189
191
  | `test_enqueue_non_serializable_data` | Enqueuing a `set` raises `TypeError` | Non-JSON-serializable data is rejected. |
190
192
  | `test_decode_round_trip` | JSON encode → decode produces the original dict | Serialization preserves data. |
191
193
  | `test_concurrent_enqueue` | Two threads each enqueuing 10 events concurrently → 20 rows, no corruption | Thread safety of concurrent writes. |
@@ -98,6 +98,41 @@ class TestHeartbeatSpoolPolicy:
98
98
  # Heartbeat events should not be spooled
99
99
  assert len(spooled) == 0
100
100
 
101
+ def test_heartbeat_alternating_success_and_failure_does_not_grow_queue(self, setup, monkeypatch):
102
+ """Heartbeat events must not increase the spool queue, even when delivery fails."""
103
+ forwarder = _new_forwarder(
104
+ "example",
105
+ forwarder_name="webhook",
106
+ forwarder_opts={"url": "http://example.invalid/api"},
107
+ )
108
+
109
+ results = iter([True, False, True, False])
110
+ monkeypatch.setattr(forwarder, "submit", lambda fe: next(results))
111
+
112
+ def make_heartbeat_event(seq):
113
+ event = _formatted_event({"description": f"heartbeat {seq}"})
114
+ event.is_heartbeat = True
115
+ event.payload = {"seq": seq}
116
+ event.summary = f"heartbeat {seq}"
117
+ return event
118
+
119
+ events = [
120
+ {"description": "heartbeat 1"},
121
+ {"description": "heartbeat 2"},
122
+ {"description": "heartbeat 3"},
123
+ {"description": "heartbeat 4"},
124
+ ]
125
+
126
+ monkeypatch.setattr(
127
+ forwarder,
128
+ "format_event",
129
+ lambda raw: make_heartbeat_event(raw["description"]),
130
+ )
131
+
132
+ for event in events:
133
+ forwarder.forward(event)
134
+ assert forwarder.num_spooled_events() == 0
135
+
101
136
  def test_webhook_non_heartbeat_failure_spools(self, setup, monkeypatch):
102
137
  """Non-heartbeat events are spooled on failure."""
103
138
  forwarder = _new_forwarder(
@@ -562,12 +597,80 @@ class TestSpoolReplayEdges:
562
597
  # All 12 events should still be in spool (all failed)
563
598
  assert forwarder.num_spooled_events() == 12
564
599
 
565
- # Stuck detection should have triggered after 2 iterations (10 fetched, then 2 remaining = unchanged)
600
+ # Stuck detection should have triggered after the first batch (10 fetched, none replayed)
566
601
  assert any("could not be submitted" in str(c) for c in critical_calls)
567
602
 
568
603
  # spool replay summary should be logged
569
604
  assert any("spool replay summary" in str(c) for c in info_calls)
570
605
 
606
+ def test_flush_terminates_while_other_processes_spool(self, setup, monkeypatch):
607
+ """Flush must not loop forever when concurrent processes keep spooling.
608
+
609
+ Regression: stuck detection compared the total spool count, which
610
+ other processes change by spooling, so the same failing head batch
611
+ was retried endlessly while the flush lock was held.
612
+ """
613
+ forwarder = _new_forwarder(
614
+ "example",
615
+ forwarder_name="webhook",
616
+ forwarder_opts={"url": "http://example.invalid/api"},
617
+ )
618
+ for i in range(12):
619
+ forwarder.spool_store.enqueue({"HOSTNAME": "h1", "event_num": i})
620
+
621
+ # a second connection plays a concurrent notificationforwarder process
622
+ other_process = SpoolStore(forwarder.db_file, forwarder.table_name)
623
+ other_process.open()
624
+
625
+ def format_ok(raw):
626
+ fe = _formatted_event(raw)
627
+ fe.payload = {"data": "test"}
628
+ fe.summary = "test"
629
+ return fe
630
+
631
+ submitted = []
632
+
633
+ def fail_while_others_spool(formatted_event):
634
+ submitted.append(formatted_event.eventopts["event_num"])
635
+ other_process.enqueue({"HOSTNAME": "h1", "event_num": 1000 + len(submitted)})
636
+ if len(submitted) > 100:
637
+ raise SystemExit("flush does not terminate")
638
+ return False
639
+
640
+ monkeypatch.setattr(forwarder, "format_event", format_ok)
641
+ monkeypatch.setattr(forwarder, "submit", fail_while_others_spool)
642
+
643
+ critical_calls = []
644
+ original_logger = baseclass.logger
645
+
646
+ class MockLogger:
647
+ def critical(self, msg, context=None, *args, **kwargs):
648
+ critical_calls.append((msg, context))
649
+
650
+ def info(self, msg, *args, **kwargs):
651
+ pass
652
+
653
+ def warning(self, msg, *args, **kwargs):
654
+ pass
655
+
656
+ def debug(self, msg, *args, **kwargs):
657
+ pass
658
+
659
+ baseclass.logger = MockLogger()
660
+ try:
661
+ forwarder.flush()
662
+ finally:
663
+ baseclass.logger = original_logger
664
+ other_process.close()
665
+
666
+ # one batch failed completely -> stop, every event tried at most once
667
+ assert submitted == list(range(10))
668
+ # nothing lost: 12 original + 10 spooled concurrently
669
+ assert forwarder.num_spooled_events() == 22
670
+ # the stuck log counts only the rows of this flush's snapshot
671
+ stuck = [ctx for msg, ctx in critical_calls if msg == "spooled events could not be submitted"]
672
+ assert stuck == [{'spooled_count': 12, 'action': 'could_not_submit'}]
673
+
571
674
  def test_formatter_returns_none_during_replay_deletes_trash(self, setup, monkeypatch):
572
675
  """Formatter returning None during replay deletes event as trash."""
573
676
  forwarder = _new_forwarder(
@@ -773,6 +876,31 @@ class TestSpoolStore:
773
876
 
774
877
  store.close()
775
878
 
879
+ def test_prune_expired_does_not_hold_write_lock(self, setup):
880
+ """After prune_expired another process must be able to spool.
881
+
882
+ Regression: the DELETE was not committed, so the implicit transaction
883
+ kept the database write-locked during the whole flush.
884
+ """
885
+ db_path = os.path.join(OMD_ROOT, "tmp", "test_spool_prune_lock.db")
886
+ store = SpoolStore(db_path, "test_events")
887
+ store.open()
888
+ store.init_db()
889
+ store.enqueue({"event": "keep"})
890
+
891
+ store.prune_expired(5)
892
+
893
+ other_process = sqlite3.connect(db_path, timeout=0.1)
894
+ try:
895
+ other_process.execute("INSERT INTO test_events(payload) VALUES (?)", ('{"event": "other"}',))
896
+ other_process.commit()
897
+ finally:
898
+ other_process.close()
899
+
900
+ assert store.count() == 2
901
+ assert store.count(max_id=1) == 1
902
+ store.close()
903
+
776
904
  def test_enqueue_non_serializable_data(self, setup):
777
905
  """SpoolStore.enqueue with non-serializable data raises."""
778
906
  import json as json_mod
@@ -77,6 +77,23 @@ def test_runtime_config_normalizes_forwarder_options(runtime_setup):
77
77
  del os.environ["NOTIFICATIONFORWARDER_MAX_SPOOL_MINUTES"]
78
78
 
79
79
 
80
+ def test_env_var_max_spool_minutes_used_as_fallback(runtime_setup):
81
+ os.environ["NOTIFICATIONFORWARDER_MAX_SPOOL_MINUTES"] = "15"
82
+
83
+ config = RuntimeConfig.from_inputs(
84
+ "example",
85
+ None,
86
+ "example",
87
+ verbose=False,
88
+ debug=False,
89
+ forwarder_opts={},
90
+ )
91
+
92
+ assert config.max_spool_minutes == 15
93
+
94
+ del os.environ["NOTIFICATIONFORWARDER_MAX_SPOOL_MINUTES"]
95
+
96
+
80
97
  def test_resolve_component_supports_derived_and_explicit_names():
81
98
  assert resolve_component("json", "Logger") == ("json", "JsonLogger", "derived-class")
82
99
  assert resolve_component("custom.module.LoggerClass", "Logger") == (