Architecture¶
This document records the design decisions for opssignal and the trade-offs behind them. If you're implementing a new provider, sink, or binding, read this first.
Components¶
flowchart TB
subgraph callers[Callers]
PY["Python: opssignal.notify()<br/>Airflow failure_callback"]
CLI["CLI: opssignal notify"]
HOST["Future Java / Go hosts"]
end
subgraph bindings[Thin binding layers]
PYO3["crates/python<br/>PyO3 extension"]
CLIC["crates/cli<br/>clap binary"]
FFI["crates/ffi<br/>C ABI, catch_unwind on every entry point"]
end
subgraph core["crates/core (all business logic)"]
SRC["source + reload<br/>file / https / s3 / gs / az, cache, polling"]
CFG["config<br/>signal.yaml + ${VAR} interpolation"]
CLIENT["SignalClient<br/>owns a Tokio runtime"]
VAL["signal<br/>validate, id, timestamp, dedup key"]
ROUTE["routing<br/>resolve(), console fallback"]
PROV["providers<br/>console, slack, webhook,<br/>pagerduty, teams"]
SINK["sink<br/>SqliteSink"]
end
PY --> PYO3
CLI --> CLIC
HOST -.-> FFI
PYO3 --> CLIENT
CLIC --> CLIENT
FFI --> CLIENT
SRC --> CFG
CFG --> CLIENT
CLIENT --> VAL
CLIENT --> ROUTE
ROUTE --> PROV
CLIENT --> SINK
PROV --> EXT["Slack / Teams / PagerDuty / HTTP endpoints / stdout"]
SINK --> DB[("signals.db")]
The Python and FFI bindings each hold one process-global SignalClient,
built lazily from signal.yaml on the first call. The CLI builds one per
invocation.
Lifecycle of one signal¶
sequenceDiagram
participant App as Caller
participant Client as SignalClient
participant Router as RoutingConfig
participant Provider
participant Sink as SqliteSink
App->>Client: notify_async(input)
Client->>Client: validate, assign id + timestamp + dedup key
Client->>Sink: record(signal), status pending
Client-->>App: returns
Client->>Router: plan(signal)
Router-->>Client: delivery targets (never empty, console fallback)
par each target, concurrently
loop each provider of the target until one succeeds
Client->>Sink: provider_outcome(pending)
Client->>Provider: send(signal), up to 3 attempts with backoff
Provider-->>Client: Ok / Err
Client->>Sink: provider_outcome(delivered / failed)
end
end
Client->>Sink: complete(signal, delivery result)
notify_sync runs the same steps but blocks the caller until the result is
known or the timeout expires.
A delivery plan is a list of targets, each an ordered list of providers. A
failover route's providers join the one shared failover target; each
provider of a fanout route is a target of its own. Targets run concurrently
and never share retries, so a provider that delivered is never sent the
signal again because another provider failed. The overall status is
delivered (every target succeeded), partial (some did) or failed. A
config without fanout has a single target and behaves exactly as it did
before delivery modes existed.
Routes that set dedup or rate_limit are checked while the plan is built,
on the caller's thread, so repeats are judged in the order they were sent. A
route held back adds no providers; if every matching route was held back, the
plan is empty and the signal is stored as skipped (the console fallback is
only for signals that match nothing). The history is a throttle table in the
sink's SQLite file, written inside an IMMEDIATE transaction, so every
process on a host that shares the file sees the same windows. A send that
ends failed is removed again so it can't suppress the next occurrence. Any
error reading the history lets the signal through. Coordinating across hosts
is deliberately not part of the embedded SDK.
notify_async callers wait only for the local sink write, never for a
provider. Its deliveries run on the client's runtime, so a process that exits
would cut them off. SignalClient::flush(timeout) waits for them, and every
binding calls it on the way out: dropping a Rust SignalClient flushes for up
to 5 seconds, the Python package registers flush() with atexit, and C
hosts call signal_flush(timeout_ms) before exiting (the library holds a
process-global client that is never dropped). The CLI uses notify_sync, so
it has nothing in flight. A signal still undelivered when the wait runs out
stays in the sink as pending.
Signal model¶
pub struct Signal {
pub id: Uuid, // core-generated, never caller-supplied
pub source: String,
pub event_type: String,
pub severity: Severity, // closed enum, not a free string
pub title: String,
pub message: Option<String>,
pub environment: Option<String>,
pub metadata: serde_json::Value,
pub timestamp: DateTime<Utc>, // core-stamped
pub dedup_key: Option<String>, // computed if caller omits it
// ...
}
id and timestamp are always core-generated. Every downstream component
(sink, dedup, retry tracking) needs a stable identity and ordering guarantee
that doesn't depend on every language binding remembering to set these
correctly.
Delivery semantics: sync vs async¶
Both notify_sync and notify_async share one internal pipeline
(validate → record → route → deliver → complete). The only difference is whether the
calling thread blocks on the delivery future.
Default for the Airflow adapter is async (fire-and-forget). A blocking
call on a flaky Slack webhook inside an on_failure_callback would add
latency and a new failure mode to the very DAG run being monitored. Callers
who want a delivery guarantee can call notify_sync directly with an
explicit timeout.
The design rule is that persistence to the sink happens before delivery
is attempted, regardless of sync/async: the signal is stored as pending,
then updated to delivered or failed. A failed delivery is durably
recorded, and a signal whose process died mid-delivery is still visible as
pending rather than lost. This is what makes delivery analytics and MTTR
reporting possible later without re-architecting.
A sink error never stops delivery. If record fails it is logged and the
signal is delivered anyway; complete writes the whole row, so the outcome
still lands if the sink has recovered.
The storage boundary¶
pub trait SignalSink: Send + Sync {
/// Before delivery: store the signal as pending.
fn record(&self, signal: &Signal) -> Result<(), SignalError>;
/// After delivery: store the outcome (an upsert; must work without `record`).
fn complete(&self, signal: &Signal, result: &DeliveryResult) -> Result<(), SignalError>;
/// During delivery: one provider's outcome, pending then delivered/failed.
/// Optional; defaults to doing nothing.
fn provider_outcome(&self, signal: &Signal, outcome: &ProviderOutcome) -> Result<(), SignalError>;
}
A sink is a write-only side effect, not a queryable API. This SDK does not expose querying, dashboards, or aggregation — that's explicitly out of scope for the open-source core (see "what this is not" in the README) and is the hosted control plane's job, reading from the same SQLite/Postgres file OSS users already populate.
- SQLite is the default sink: zero-config, works out of the box.
- Postgres / S3 are opt-in for centralized analytics across multiple workers — relevant once you outgrow a single-node Airflow deployment, since SQLite's single-writer model will start serializing writes under concurrent load.
Routing¶
Routing config matches signals against rules and resolves to a list of provider names.
Invariant: unmatched signals always fall back to the console provider.
A signal that matches no route must never silently disappear — that failure
mode is worse than not having the SDK at all. This is enforced in
RoutingConfig::resolve, not left to configuration.
Config sources and reload¶
SIGNAL_CONFIG can name a local file, an https://, s3://, gs:// or
az:// object, an AWS AppConfig profile (appconfig://), an Azure App
Configuration key (azappconfig://), or one item of DynamoDB (dynamodb://),
Firestore (firestore://) or Cosmos DB (cosmos://)
(crates/core/src/source.rs). The cloud
code lives in crates/core/src/cloud/, one module per cloud, and non-storage
services are signed with the credentials object_store already resolves (SigV4
on AWS; Entra tokens for the service's own audience on Azure), so no cloud SDK
is in the tree. The cloud stores sit behind the
aws, gcp and azure cargo features of opssignal-core, so the core stays
light. They run on the same reqwest/rustls stack as the providers rather than
pulling in a second one.
Cloud code ships only in cloud packages. The base CLI and C library compile
none of it; their aws, gcp, azure and all features build a variant
that is the base plus that cloud. The Python wheel compiles none either:
each cloud ships as its own wheel (crates/python-aws, -gcp, -azure),
installed through the opssignal[aws], [gcp] and [azure] extras, because
pip extras can add packages but cannot change what is compiled into one. At
startup the base binding imports whichever add-ons are installed and registers
each one's fetch with the core for every scheme of its cloud
(source::register_cloud_fetcher). The core calls
it on a plain thread for that scheme, with the same cache and fallback rules.
An add-on is a thin wrapper around source::fetch_blocking; it has no logic of
its own.
Two reliability rules shape this code:
- A config source is never a reason to lose a signal. A remote source that
is unreachable or serves a broken file falls back to the last good cached
copy, then to the zero-config default (console only). The cache holds the
raw text before
${VAR}substitution, so it never contains secrets.${aws-sm:...},${gcp-sm:...}and${azure-kv:...}placeholders are resolved bysecrets.rsafter each file is fetched (values cached in memory forSIGNAL_SECRETS_TTL_SECS); a store that can't be reached is handled like an unreachable source. - A reload is all or nothing.
reload.rspolls the source on an interval (default 60 s) and, when the text or a fingerprint of its resolved secrets changes and loads cleanly, swaps routes and providers together as onePipelinebehind anRwLock<Arc<_>>. In-flight deliveries keep theArcthey started with. A failed reload keeps the current pipeline. The sink is never reopened live.
A config can be layered: a file may extends: others, and SIGNAL_CONFIG may
list several (crates/core/src/chain.rs). The chain is walked depth first
into a list of files, lowest priority first, with each file loaded once,
cycles rejected, and a depth (8) and file (64) limit. Config::from_layers
merges it: providers by name, later routes first, storage from the last file
that sets it. Routes are checked only after the merge. Each remote file is
cached on its own, and the reloader re-walks the whole chain on every poll and
compares the texts of every file, so a change to a shared default reaches all
the teams that extend it. A remote file may not extend a local path.
Polling is used instead of filesystem notifications because Kubernetes updates ConfigMap mounts by swapping a symlink, which file watchers often miss, and the same loop covers remote sources.
FFI panic safety¶
Every #[no_mangle] extern "C" function in crates/ffi must be wrapped in
catch_unwind (see crates/ffi/src/lib.rs::guarded). A Rust panic crossing
the FFI boundary is undefined behavior in the host language and can crash
the calling process outright — for an embedded SDK, that's worse than the
failure it was supposed to report on.
The Python bindings (crates/python) additionally translate Result::Err
into real Python exceptions via PyO3 — never a silently swallowed None.
Why two separate Rust crates for bindings (ffi vs python)¶
crates/ffi is a generic C ABI layer intended for future Java/Go bindings
via JNI/cgo. crates/python uses PyO3 macros directly, which is a different
(and for Python, more idiomatic and lower-overhead) binding mechanism than
hand-rolled C ABI. Both wrap the same opssignal-core logic; neither contains
business logic of its own.
What's deliberately out of scope¶
- Event bus / message queue semantics
- Event sourcing, complex event processing
- Long-running workflow orchestration
- Java and Go bindings (designed for, not built — see roadmap in README)
- Querying/dashboards over stored signals (hosted control plane territory)
Revisit triggers¶
| Decision | Revisit when |
|---|---|
| Embedded Tokio runtime | If embedding conflicts with host apps that have their own async runtime become common |
| SQLite default sink | When users run multi-worker Airflow and need unified cross-worker history |
| No bounded buffering/backpressure | If burst scenarios (thousands of signals in a tight loop) overwhelm provider rate limits |
| Hash-based dedup | If semantically-identical-but-textually-different signals need correlation (likely an AI-correlation premium feature, not OSS core) |