Notifications¶
omniload can send a short report when a load ends, successful or not, to any target Apprise supports, such as Slack, ntfy, email or a webhook. Unattended runs (cron, Kubernetes jobs, AWS Lambda) then report their outcome without a wrapper script that parses logs.
Install¶
The notification support is an optional extra:
pip install 'omniload[notify]'
It is part of omniload[full]. Without the extra, a run that asks for a
notification stops before the load starts and names the extra to install.
Usage¶
Name an Apprise URL with --notify. Each URL receives one report per run, for
the run’s final outcome:
omniload ingest \
--source-uri 'postgresql://user:pass@localhost/app' \
--source-table 'public.orders' \
--dest-uri 'duckdb:///warehouse.duckdb' \
--dest-table 'raw.orders' \
--notify 'ntfys://ntfy.sh/nightly-loads'
--notify can be given more than once. Some targets, as Apprise URLs:
email:
mailto://user:pass@example.comntfy:
ntfys://ntfy.sh/<topic>Slack:
slack://<token-a>/<token-b>/<token-c>
The Apprise wiki documents the URL format of every target. A notification URL usually carries a secret (a webhook token, a password), so treat it like a connection string.
--notify-on failure sends a report only when the run fails, for a job that
should stay quiet while it works. The default is --notify-on always.
From Python, the same options are keyword arguments of run_ingest:
from omniload import run_ingest
run_ingest(
source_uri="postgresql://user:pass@localhost/app",
source_table="public.orders",
dest_uri="duckdb:///warehouse.duckdb",
dest_table="raw.orders",
notify=["ntfys://ntfy.sh/nightly-loads"],
notify_on="failure",
)
Two inputs that add up¶
Inline URLs suit a one-off run. Routing, where different targets hear about different outcomes, lives in an Apprise config file. omniload takes both:
--notify <apprise-url>: repeatable,OMNILOAD_NOTIFY,notify=[...]onrun_ingest.--notify-config <path-or-url>: repeatable,OMNILOAD_NOTIFY_CONFIG,notify_config=[...]onrun_ingest. It takes whatever Apprise reads as a config source: a local YAML or TEXT file, or a remote one overhttp(s)://.
Given together, they add up: inline URLs and every config source go into one
Apprise object, and each run makes a single notify() call. A flag replaces its
own environment variable, as everywhere else in ingest, but --notify and
--notify-config never replace each other.
# Ad hoc: one target, every outcome.
omniload ingest --source-uri ... --source-table ... --dest-uri ... --dest-table ... \
--notify 'ntfys://ntfy.sh/nightly-loads'
# Routed by a config file, plus an inline target for this run.
omniload ingest ... --notify-config notify.yml --notify 'mailto://user:pass@example.com'
The environment variables take several values separated by whitespace, for
example OMNILOAD_NOTIFY='ntfys://ntfy.sh/a mailto://user:pass@example.com'.
Note
Like --columns, and unlike the other ingest options, these three have no bare
environment variable name (NOTIFY, NOTIFY_CONFIG, NOTIFY_ON), and omniload ignores Apprise’s own
discovery: APPRISE_URLS, APPRISE_CONFIG_PATH and the default config paths
such as ~/.apprise.yml. A notification is a side effect, so a run only sends
one when its own invocation asks for it.
Routing with a config file¶
omniload sends each report with the outcome as its Apprise tag, success or
failure, and as its notification type. Inline URLs receive every report that
goes out. Config entries route by their own tags:
always: every report.failure: reports of failed runs.success: reports of successful runs.
# notify.yml: Slack hears about every run, ntfy only about failures.
urls:
- slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ:
tag: always
- ntfys://ntfy.sh/nightly-failures:
tag: failure
--notify-on failure still decides whether a report goes out at all, config
targets included, while tags decide where it goes. One shared config file can
then serve a job that only wants failures.
The rest of the format is Apprise’s own, and the Apprise config pages describe it. Three parts of it shape what omniload sends:
Includes:
include:is followed one level deep, asapprise -cdoes by default. A remote config cannot include a local file; Apprise refuses that include and keeps the remote file’s own entries.Priorities: a tag with a priority (
1:failure) escalates as it does in Apprise. Entries of the lowest priority go first, and the next group is only tried when one of them fails. Inline--notifyURLs andfailureentries without a priority are priority 0, so while they succeed, a1:failureentry receives nothing. Entries taggedalwayssit outside that escalation: each of them is sent to, whatever its priority, and none of them holds back a1:failureentry.Untagged entries receive nothing, since that is how Apprise matches a tag filter. Give every entry a tag.
Checks before the load¶
Apprise accepts a missing file, broken YAML or an empty file without an error,
so omniload reads each config source on its own before the load starts. It
stops the run with an error naming the source by position and scheme, for
example --notify-config #2 (file), when:
An inline URL is not a URL Apprise knows.
Apprise cannot read the config source at all.
The config file does not exist.
The source holds no usable entry tagged
success,failureoralways. That covers broken YAML, an empty file, a file of unknown schemes, a file of only untagged or differently tagged entries, and a${NAME}template that has no value.
A broken config therefore fails under --dry-run, which reads it and sends
nothing. A file with one bad entry among good ones runs, without the bad entry,
as it would under Apprise.
A remote config that yields nothing is the exception in a real run: it logs a
warning and the load goes ahead, so a config server being down does not stop a
nightly job. Under --dry-run the same config is an error. omniload reads each
remote config once, before the load, and sends to what it read: the
notification makes no second fetch, and a dry run still fetches the config.
To see why Apprise rejects a config, run Apprise’s own check, outside omniload:
apprise -c notify.yml --dry-run
Warning
apprise --dry-run prints the targets it would notify, and its output can
contain secrets: header values in particular (+Authorization=... in a
json:// or jsons:// URL) appear unmasked. Do not paste it into a ticket.
Opening a GitHub issue on failure¶
Apprise has no GitHub target yet. Its jsons:// target can call the GitHub
REST API instead, as the Apprise maintainer showed on the omniload
tracker:
# notify.yml
template:
- github_token
urls:
- "jsons://api.github.com/repos/OWNER/REPO/issues?+Authorization=Bearer%20${github_token}&+Accept=application/vnd.github%2Bjson&:message=body&:version=&:attachments=&:type=":
tag: failure
export APPRISE_TEMPLATE_GITHUB_TOKEN='<token>'
omniload ingest ... --notify-config notify.yml
The + parameters set the request headers, :message=body renames the report
body to the field GitHub expects, and the empty :version=, :attachments= and
:type= drop fields GitHub does not take. Each POST creates a new issue (the
GitHub create-an-issue endpoint), titled with the report’s title.
Important
Route it by
failure. Every report sent to it opens another issue. An inline--notifyURL receives every outcome, so use this recipe from a config file, or inline only together with--notify-on failure.Keep the token out of the file and the shell history. The example reads it from Apprise’s template variable
APPRISE_TEMPLATE_GITHUB_TOKEN, set by your scheduler or secret store. A config file that holds the token needs tight file permissions. Without a value, the entry cannot resolve, and the check before the load fails.
What the report contains¶
A report is short, in Markdown, with a one-line title such as
omniload failure at load: postgresql → duckdb:
Status: **success**
- Source: `file`
- Destination: `duckdb` / `out.t`
- Started: 2026-10-18T19:31:55+00:00
- Finished: 2026-10-18T19:31:55+00:00
- Duration: 0.59 seconds
- Load packages: 1
- Failed jobs: 0
- omniload: `0.20.0`
Rows normalized:
- `t`: 3 rows
A failed run adds the stage it stopped at (setup, staging, extract,
normalize, load, destination_post_load, source_post_load or
destination_close, or pipeline for any other dlt step) and the type
of the exception, such as NoSuchTableError. Row counts are the rows dlt
normalized, not rows committed, without dlt’s own state tables. A count omniload
does not know for the run is shown as unavailable, never as 0.
The report leaves out anything that can carry a credential:
Exception and job messages: only the exception type appears. The full error stays in the run’s log.
Table selector payloads: a selector keeps its leading name only. A
query:selector reads ascustom query, another<kind>:<payload>selector as<kind> (custom selector), and a?or#part is dropped.URIs: only the scheme appears, never credentials, hosts or paths.
Table names dlt produced are shown when they are plain identifiers. Nested
tables are named after the keys in the data, and a spreadsheet source names
one table per worksheet, so keys and sheet names can appear. When the
destination table selector itself had a part redacted, the report numbers the
tables (table 1) instead.
When sending fails¶
The report goes out after the load has committed and the source has run its own commit or cleanup, so a notification never delays an acknowledgement.
Once the load has run, a report that cannot be built or delivered (a target
that is down, a timeout, a partial delivery) logs one warning, which names a
failed target by its scheme at most. The return value, the exception and the exit status
stay what they would have been without the notification. Each notify() call
waits at most 30 seconds. A target still sending after that keeps going until
its own network timeouts end it, and the process waits for it before exiting.
A run stopped by KeyboardInterrupt or SystemExit sends no report. An
interrupt while the report is being sent interrupts as usual. A SystemExit
raised while the report is being sent, by a plugin or by a signal handler of a
program that embeds omniload, is logged as a failed send and does not end
the run.
What omniload keeps out of the log¶
Apprise’s own log lines, and those of the libraries it sends through, print
notification URLs. While omniload calls Apprise, it drops every log record
created in that call, its own excepted, including records a delivery thread
writes after a timeout. It does so with a hook on logging.Logger.handle,
installed the first time a run is given a notification target. The hook is inert outside
those calls, so everything else logs as usual. omniload’s own messages name a
target by position and scheme only.
What the hook does not cover:
A logger class that overrides
handle, or code that kept a reference toLogger.handlefrom before omniload installed the hook.Output written with
warnings.warn, which does not go through logging unlesslogging.captureWarnings(True)is set.urllib3warns this way, naming the host, when a target turns off certificate checks (?verify=no).Threads a plugin starts without the calling context, such as the XMPP plugin’s own thread.
Of these, the XMPP thread and the urllib3 warning occur with Apprise 2.0 and
the libraries it sends through.