omdnotificationforwarder 4.4__tar.gz → 4.6__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.6}/PKG-INFO +171 -4
  2. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/README.md +169 -2
  3. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/bin/notificationforwarder +1 -1
  4. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/pyproject.toml +1 -1
  5. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/baseclass.py +105 -61
  6. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/logger.py +2 -0
  7. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/spool.py +43 -6
  8. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/logger.py +5 -0
  9. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/TESTS.md +31 -3
  10. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_delivery_resilience.py +35 -0
  11. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_loggers.py +37 -0
  12. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_resilience_spool.py +514 -13
  13. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_runtime_foundation.py +17 -0
  14. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/.gitignore +0 -0
  15. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/component_loader.py +0 -0
  16. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/formatter.py +0 -0
  17. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/email/forwarder.py +0 -0
  18. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/formatter.py +0 -0
  19. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/example/forwarder.py +0 -0
  20. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/json/__init__.py +0 -0
  21. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/naemonlog/reporter.py +0 -0
  22. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/__init__.py +0 -0
  23. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/formatter.py +0 -0
  24. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/nostr/forwarder.py +0 -0
  25. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/__init__.py +0 -0
  26. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/oauth2webhook/forwarder.py +0 -0
  27. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/formatter.py +0 -0
  28. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/rabbitmq/forwarder.py +0 -0
  29. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_config.py +0 -0
  30. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/runtime_flow.py +0 -0
  31. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/formatter.py +0 -0
  32. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/syslog/forwarder.py +0 -0
  33. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/telegram/forwarder.py +0 -0
  34. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/text/__init__.py +0 -0
  35. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/util.py +0 -0
  36. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/src/notificationforwarder/webhook/forwarder.py +0 -0
  37. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split1/forwarder.py +0 -0
  38. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/formatter.py +0 -0
  39. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  40. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/formatter.py +0 -0
  41. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  42. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/alertmanager_servicenow/formatter.py +0 -0
  43. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/bayern/formatter.py +0 -0
  44. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datadup/formatter.py +0 -0
  45. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/datapost/formatter.py +0 -0
  46. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/discard/formatter.py +0 -0
  47. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split1/formatter.py +0 -0
  48. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split2/forwarder.py +0 -0
  49. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/formatter.py +0 -0
  50. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split3/forwarder.py +0 -0
  51. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/formatter.py +0 -0
  52. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/split4/forwarder.py +0 -0
  53. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/forwarder.py +0 -0
  54. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/ticketsystem/reporter.py +0 -0
  55. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/pythonpath/local/lib/python/notificationforwarder/vong/formatter.py +0 -0
  56. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_alertmanager.py +0 -0
  57. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_builtin_plugins_focus.py +0 -0
  58. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_classes.py +0 -0
  59. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_contracts_formatters.py +0 -0
  60. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_contracts_forwarders.py +0 -0
  61. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_contracts_reporters.py +0 -0
  62. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_discard.py +0 -0
  63. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_formatter.py +0 -0
  64. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_integration_cli.py +0 -0
  65. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_loading_resolution.py +0 -0
  66. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_logger.py +0 -0
  67. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_nostr.py +0 -0
  68. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_oauth2webhook.py +0 -0
  69. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_orchestration_flow.py +0 -0
  70. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_package.py +0 -0
  71. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_paths.py +0 -0
  72. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/tests/test_reporter.py +0 -0
  73. {omdnotificationforwarder-4.4 → omdnotificationforwarder-4.6}/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.6
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,117 @@ 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, oldest first. Events that are successfully submitted are deleted from the spool. The first event that fails again ends the flush: it and all later events stay in the queue for the next attempt. A receiver that does not accept one event will most likely not accept the next ones either, so a flush costs at most one submit timeout instead of one per spooled event.
525
+
526
+ If the receiver rejects a spooled event for good (for example because of an invalid payload), that event must not block the queue until `max_spool_minutes` have passed. When a flush stopped at an event and the current event is then accepted by the receiver, the receiver is evidently up. The stopped event is moved to the end of the queue and logged as "spooled event moved to the end of the spool". It keeps its original time, so it is still dropped when `max_spool_minutes` have passed, and the next flush can deliver the events behind it.
527
+
528
+ 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
+
530
+ ### Delivery Order
531
+
532
+ 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:
533
+
534
+ - While one notificationforwarder process is flushing the spool, another process submits its own new event directly, ahead of the spooled ones.
535
+ - A spooled event was rejected while the receiver accepted the current event. The rejected event is moved to the end of the spool and is delivered, if at all, after newer events.
536
+ - The flush stopped early, or `probe()` returned False. The process still submits its own new event directly.
537
+ - A submit ran into the timeout, but the receiver got the event anyway. The event stays in the spool, and the receiver gets it a second time later, after newer events.
538
+
539
+ If the receiver depends on the order, let the formatter include the event's creation time (`omd_originating_timestamp`, set for every event) in the payload. The receiver or its integration can then ignore an event that is older than the state it already has.
540
+
541
+ ### Probe Method
542
+
543
+ 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()`.
544
+
545
+ The framework uses `probe()` as a pre-check with three possible scenarios:
546
+
547
+ |Forwarder has probe()?|probe() result|What happens|Why|
548
+ |---|---|---|---|
549
+ |No|—|flush proceeds|No way to check availability, must try|
550
+ |Yes|True|flush proceeds|Receiver is confirmed reachable|
551
+ |Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
552
+
553
+ 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.
554
+
555
+ Example implementation:
556
+
557
+ ```python
558
+ def probe(self):
559
+ r = requests.head(self.url)
560
+ return r.status_code == 200
561
+ ```
562
+
563
+ ### Heartbeat Events
564
+
565
+ Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
566
+
567
+ 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.
568
+
569
+ 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.
570
+
571
+ Example formatter that sets the heartbeat flag:
572
+
573
+ ```python
574
+ class MyFormatter(NotificationFormatter):
575
+ def format_event(self, event):
576
+ notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
577
+ event.is_heartbeat = notification_type == "HEARTBEAT"
578
+
579
+ event.payload = {
580
+ "hostname": event.eventopts.get("HOSTNAME", ""),
581
+ "heartbeat": event.is_heartbeat,
582
+ }
583
+ event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
584
+ ```
585
+
586
+ ### Scheduling Heartbeats in Nagios/Naemon
587
+
588
+ 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.
589
+
590
+ **Command definition:**
591
+
592
+ ```
593
+ define command {
594
+ command_name notificationforwarder_heartbeat
595
+ command_line $USER1$/notificationforwarder \
596
+ --forwarder webhook \
597
+ --forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
598
+ --formatter myformatter \
599
+ --eventopt HOSTNAME='$HOSTNAME$' \
600
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
601
+ --eventopt NOTIFICATIONTYPE=HEARTBEAT \
602
+ --eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
603
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
604
+ }
605
+ ```
606
+
607
+ **Service definition:**
608
+
609
+ ```
610
+ define service {
611
+ host_name monitoring-server
612
+ service_description notificationforwarder-heartbeat
613
+ check_command notificationforwarder_heartbeat
614
+ max_check_attempts 1
615
+ check_interval 5
616
+ retry_interval 1
617
+ notification_interval 0
618
+ notification_options n
619
+ notification_period 24x7
620
+ }
621
+ ```
622
+
623
+ The key settings:
624
+ - `check_interval 5` — runs the heartbeat every 5 minutes
625
+ - `notification_interval 0` — no Nagios-level notifications
626
+ - `notification_options n` — don't send notifications on state changes
627
+
628
+ 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.
629
+
630
+ The two use cases for periodic heartbeats:
631
+ - **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.
632
+ - **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,117 @@ 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, oldest first. Events that are successfully submitted are deleted from the spool. The first event that fails again ends the flush: it and all later events stay in the queue for the next attempt. A receiver that does not accept one event will most likely not accept the next ones either, so a flush costs at most one submit timeout instead of one per spooled event.
504
+
505
+ If the receiver rejects a spooled event for good (for example because of an invalid payload), that event must not block the queue until `max_spool_minutes` have passed. When a flush stopped at an event and the current event is then accepted by the receiver, the receiver is evidently up. The stopped event is moved to the end of the queue and logged as "spooled event moved to the end of the spool". It keeps its original time, so it is still dropped when `max_spool_minutes` have passed, and the next flush can deliver the events behind it.
506
+
507
+ 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
+
509
+ ### Delivery Order
510
+
511
+ 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:
512
+
513
+ - While one notificationforwarder process is flushing the spool, another process submits its own new event directly, ahead of the spooled ones.
514
+ - A spooled event was rejected while the receiver accepted the current event. The rejected event is moved to the end of the spool and is delivered, if at all, after newer events.
515
+ - The flush stopped early, or `probe()` returned False. The process still submits its own new event directly.
516
+ - A submit ran into the timeout, but the receiver got the event anyway. The event stays in the spool, and the receiver gets it a second time later, after newer events.
517
+
518
+ If the receiver depends on the order, let the formatter include the event's creation time (`omd_originating_timestamp`, set for every event) in the payload. The receiver or its integration can then ignore an event that is older than the state it already has.
519
+
520
+ ### Probe Method
521
+
522
+ 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()`.
523
+
524
+ The framework uses `probe()` as a pre-check with three possible scenarios:
525
+
526
+ |Forwarder has probe()?|probe() result|What happens|Why|
527
+ |---|---|---|---|
528
+ |No|—|flush proceeds|No way to check availability, must try|
529
+ |Yes|True|flush proceeds|Receiver is confirmed reachable|
530
+ |Yes|False|flush skipped|Receiver is known to be down, avoid wasted submit() calls|
531
+
532
+ 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.
533
+
534
+ Example implementation:
535
+
536
+ ```python
537
+ def probe(self):
538
+ r = requests.head(self.url)
539
+ return r.status_code == 200
540
+ ```
541
+
542
+ ### Heartbeat Events
543
+
544
+ Formatters can mark an event as a heartbeat by setting `event.is_heartbeat = True` on the `FormattedEvent` object. Heartbeat events have two special properties:
545
+
546
+ 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.
547
+
548
+ 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.
549
+
550
+ Example formatter that sets the heartbeat flag:
551
+
552
+ ```python
553
+ class MyFormatter(NotificationFormatter):
554
+ def format_event(self, event):
555
+ notification_type = event.eventopts.get("NOTIFICATIONTYPE", "")
556
+ event.is_heartbeat = notification_type == "HEARTBEAT"
557
+
558
+ event.payload = {
559
+ "hostname": event.eventopts.get("HOSTNAME", ""),
560
+ "heartbeat": event.is_heartbeat,
561
+ }
562
+ event.summary = "heartbeat from {}".format(event.eventopts.get("HOSTNAME", ""))
563
+ ```
564
+
565
+ ### Scheduling Heartbeats in Nagios/Naemon
566
+
567
+ 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.
568
+
569
+ **Command definition:**
570
+
571
+ ```
572
+ define command {
573
+ command_name notificationforwarder_heartbeat
574
+ command_line $USER1$/notificationforwarder \
575
+ --forwarder webhook \
576
+ --forwarderopt url=https://xxx.service-now.com/api/now/import/u_notification \
577
+ --formatter myformatter \
578
+ --eventopt HOSTNAME='$HOSTNAME$' \
579
+ --eventopt SERVICEDESC='$SERVICEDESC$' \
580
+ --eventopt NOTIFICATIONTYPE=HEARTBEAT \
581
+ --eventopt SERVICEOUTPUT='heartbeat from $HOSTNAME$' \
582
+ >> $USER4$/var/log/notificationforwarder_errors.log 2>&1
583
+ }
584
+ ```
585
+
586
+ **Service definition:**
587
+
588
+ ```
589
+ define service {
590
+ host_name monitoring-server
591
+ service_description notificationforwarder-heartbeat
592
+ check_command notificationforwarder_heartbeat
593
+ max_check_attempts 1
594
+ check_interval 5
595
+ retry_interval 1
596
+ notification_interval 0
597
+ notification_options n
598
+ notification_period 24x7
599
+ }
600
+ ```
601
+
602
+ The key settings:
603
+ - `check_interval 5` — runs the heartbeat every 5 minutes
604
+ - `notification_interval 0` — no Nagios-level notifications
605
+ - `notification_options n` — don't send notifications on state changes
606
+
607
+ 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.
608
+
609
+ The two use cases for periodic heartbeats:
610
+ - **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.
611
+ - **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.6')
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.6"
25
25
  authors = [
26
26
  { name="Gerhard Lausser", email="lausser@yahoo.com" },
27
27
  ]
@@ -330,6 +330,7 @@ class NotificationForwarder(object):
330
330
  return enrich_raw_event(raw_event)
331
331
 
332
332
  def forward_formatted(self, formatted_event):
333
+ self.flush_stopped_at = None
333
334
  try:
334
335
  """probe() checks if a forwarder is principally capable to submit
335
336
  an event. It is mostly used to contact an api and confirm that
@@ -376,6 +377,11 @@ class NotificationForwarder(object):
376
377
  'formatted_event': formatted_event,
377
378
  'status': 'success'
378
379
  })
380
+ if formatted_event is not None and self.flush_stopped_at is not None:
381
+ # the receiver accepted this event, so it is up. the spooled
382
+ # event at which the flush stopped was rejected for its own
383
+ # reasons and must not block the spool until it expires.
384
+ self.move_stopped_event_to_end()
379
385
  return success
380
386
  return False
381
387
 
@@ -411,7 +417,38 @@ class NotificationForwarder(object):
411
417
  def acquire_lock_with_retry(self, lock_file, max_attempts=3, base_delay=0.1):
412
418
  return acquire_lock_with_retry(lock_file, logger, max_attempts=max_attempts, base_delay=base_delay)
413
419
 
420
+ def move_stopped_event_to_end(self):
421
+ event_id = self.flush_stopped_at
422
+ self.flush_stopped_at = None
423
+ try:
424
+ with open(self.db_lock_file, "w") as lock_file:
425
+ # a running flush could hold the row in memory, so move it
426
+ # only while no other process is flushing
427
+ if not self.acquire_lock_with_retry(lock_file, max_attempts=1):
428
+ logger.debug("missed the flush lock, rejected event stays in place", {
429
+ 'event_id': event_id,
430
+ })
431
+ return
432
+ try:
433
+ new_event_id = self.spool_store.move_to_end(event_id)
434
+ finally:
435
+ import fcntl
436
+ fcntl.lockf(lock_file, fcntl.LOCK_UN)
437
+ if new_event_id is not None:
438
+ logger.critical("spooled event moved to the end of the spool", {
439
+ 'event_id': event_id,
440
+ 'new_event_id': new_event_id,
441
+ 'action': 'moved_to_end',
442
+ })
443
+ except Exception as e:
444
+ logger.critical("could not move rejected spooled event", {
445
+ 'event_id': event_id,
446
+ 'exception': e,
447
+ })
448
+
414
449
  def flush(self):
450
+ # id of the spooled event at which the flush stopped, see move_stopped_event_to_end()
451
+ self.flush_stopped_at = None
415
452
  with open(self.db_lock_file, "w") as lock_file:
416
453
  locked = self.acquire_lock_with_retry(lock_file)
417
454
  if locked:
@@ -427,75 +464,82 @@ class NotificationForwarder(object):
427
464
  'spooled_count': dropped,
428
465
  'action': 'dropped'
429
466
  })
430
- last_events_to_flush = 0
467
+ # walk through the rows which existed when the flush started.
468
+ # rows spooled meanwhile by concurrent processes are left for the
469
+ # next flush and each row is tried at most once. the first failed
470
+ # submit ends the flush, because a receiver which does not accept
471
+ # one event will most likely not accept the next ones either and
472
+ # every further attempt would cost another submit timeout.
473
+ last_id = 0
474
+ snapshot_max_id = self.spool_store.max_id()
431
475
  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:
476
+ id_events = self.spool_store.fetch_batch(after_id=last_id, max_id=snapshot_max_id)
477
+ if not id_events:
439
478
  logger.debug("nothing left to flush", {})
440
479
  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'
480
+ logger.info("spooled events to be re-sent", {
481
+ 'spooled_count': len(id_events),
482
+ 'action': 'resend'
483
+ })
484
+ for id, text in id_events:
485
+ last_id = id
486
+ raw_event = self.spool_store.decode(text)
487
+ formatted_event = self.format_event(raw_event)
488
+ if formatted_event and formatted_event.is_discarded:
489
+ deleted_trash += 1
490
+ logger.info("discard spooled event during replay", {
491
+ 'event_id': id,
492
+ 'action': 'discard_during_replay'
446
493
  })
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
- })
494
+ self.spool_store.delete(id)
495
+ elif formatted_event:
496
+ replay_attempted += 1
497
+ try:
498
+ result = self.submit(formatted_event)
499
+ success, _report_payload, error_message = apply_forward_result(result)
500
+ except Exception as e:
501
+ success = False
502
+ error_message = str(e)
503
+ if success:
459
504
  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,
505
+ replayed += 1
506
+ logger.info("delete spooled event", {
490
507
  'spooled_count': 1,
491
- 'action': 'could_not_format'
492
- })
493
- self.spool_store.delete(id)
494
- logger.info("delete trash event", {
495
508
  'event_id': id,
496
- 'action': 'delete_trash'
509
+ 'action': 'delete'
497
510
  })
498
- last_events_to_flush = events_to_flush
511
+ else:
512
+ stayed_in_spool += 1
513
+ context = {
514
+ 'event_id': id,
515
+ 'action': 'stays_in_spool'
516
+ }
517
+ if error_message:
518
+ context['exception'] = error_message
519
+ logger.critical("event stays in spool", context)
520
+ self.flush_stopped_at = id
521
+ break
522
+ else:
523
+ deleted_trash += 1
524
+ logger.critical("could not format spooled event", {
525
+ 'raw_event': raw_event,
526
+ 'event_id': id,
527
+ 'spooled_count': 1,
528
+ 'action': 'could_not_format'
529
+ })
530
+ self.spool_store.delete(id)
531
+ logger.info("delete trash event", {
532
+ 'event_id': id,
533
+ 'action': 'delete_trash'
534
+ })
535
+ if self.flush_stopped_at is not None:
536
+ # only the rows this flush was responsible for, not
537
+ # the ones spooled concurrently after the snapshot
538
+ logger.critical("spooled events could not be submitted", {
539
+ 'spooled_count': self.spool_store.count(max_id=snapshot_max_id),
540
+ 'action': 'could_not_submit'
541
+ })
542
+ break
499
543
  logger.info("spool replay summary", {
500
544
  'attempted': replay_attempted,
501
545
  'recovered_count': replayed,
@@ -121,6 +121,8 @@ class JsonLogger(NotificationLogger):
121
121
  msg_data["split_count"] = context['split_count']
122
122
  if 'event_id' in context:
123
123
  msg_data["event_id"] = context['event_id']
124
+ if 'new_event_id' in context:
125
+ msg_data["new_event_id"] = context['new_event_id']
124
126
 
125
127
  # Add action type
126
128
  if 'action' in context: