Configuration¶
Where the config comes from¶
OpsSignal looks for its config in this order:
-
The
SIGNAL_CONFIGenvironment variable. It can be a local path or a URL:Value Source Notes /etc/opssignal/signal.yamllocal file Must exist, or loading fails. https://config.example.com/signal.yamlHTTPS GETPlain http://is accepted only forlocalhost.s3://bucket/path/signal.yamlAmazon S3 (or any S3-compatible store) Python: opssignal[aws].gs://bucket/path/signal.yamlGoogle Cloud Storage Python: opssignal[gcp].az://container/path/signal.yamlAzure Blob Storage Python: opssignal[azure]. SetAZURE_STORAGE_ACCOUNT_NAMEto the storage account.appconfig://application/environment/profileAWS AppConfig Python: opssignal[aws]. See Managed config services.azappconfig://store/key?label=prodAzure App Configuration Python: opssignal[azure].?label=is optional.dynamodb://table/idAmazon DynamoDB item Python: opssignal[aws]. See Databases.firestore://project/collection/documentFirestore document Python: opssignal[gcp].cosmos://account/database/container/idAzure Cosmos DB item Python: opssignal[azure]. -
./signal.yamlin the current working directory. - Nothing found: the zero-config default (console provider, SQLite sink at
./signals.db).
SIGNAL_CONFIG can also list several files, separated by commas, which are
merged as described in Layered configs. The CLI also
accepts --config <path or URL> (or a comma-separated list), which overrides
both.
Installing cloud support¶
pip install opssignal reads local files and HTTPS only. Each cloud is a pip
extra that installs a small native add-on, which opssignal finds on its own:
| Install | Adds |
|---|---|
pip install 'opssignal[aws]' |
s3://, appconfig://, dynamodb://, ${aws-sm:...} secrets, email through the SES API |
pip install 'opssignal[gcp]' |
gs://, firestore://, ${gcp-sm:...} secrets |
pip install 'opssignal[azure]' |
az://, azappconfig://, cosmos://, ${azure-kv:...} secrets |
pip install 'opssignal[all]' |
all three |
If SIGNAL_CONFIG names a cloud whose extra isn't installed, the first
notify() raises RuntimeError naming the extra to install (the CLI exits
with code 2 and names the feature to build with). That is treated
as a setup mistake, so it fails straight away instead of falling back.
The CLI and the C library follow the same rule: the base build reads local files and HTTPS only, and each cloud is added on top of it with a cargo feature named like the pip extra:
| Build | Adds |
|---|---|
cargo build -p opssignal-cli --features aws |
s3://, appconfig://, dynamodb://, ${aws-sm:...}, email through the SES API |
cargo build -p opssignal-cli --features gcp |
gs://, firestore://, ${gcp-sm:...} |
cargo build -p opssignal-cli --features azure |
az://, azappconfig://, cosmos://, ${azure-kv:...} |
cargo build -p opssignal-cli --features all |
all three |
The same features exist on opssignal-ffi for the C library. If you depend on
the opssignal-core crate directly, the features are aws, gcp and azure;
without them the core reads files and HTTPS only.
Remote configs¶
One signal.yaml in a bucket can serve every service. Each process sets
SIGNAL_CONFIG to the same URL and keeps its secrets in its own environment
(see Environment variables in values), so
the shared file never holds a token.
Credentials. OpsSignal uses each platform's standard credential chain and has no keys of its own:
- S3:
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, web identity (EKS IRSA), ECS task roles, or the EC2 instance profile. SetAWS_REGIONto the bucket's region.AWS_ENDPOINT_URLpoints it at MinIO or another S3-compatible store. - GCS:
GOOGLE_APPLICATION_CREDENTIALSorGOOGLE_SERVICE_ACCOUNT, gcloud application-default credentials, then the metadata server (GKE workload identity, GCE service accounts). - Azure: AKS workload identity (
AZURE_FEDERATED_TOKEN_FILE, set by the workload identity webhook), managed identity, a service principal (AZURE_CLIENT_ID/AZURE_CLIENT_SECRET/AZURE_TENANT_ID), a SAS token (AZURE_STORAGE_SAS_KEY) or an account key. - HTTPS: no authentication; use a URL that is reachable only from your network, or a pre-signed URL.
The process only needs read access to that one object (s3:GetObject,
storage.objects.get, or the Storage Blob Data Reader role on Azure).
Managed config services¶
AWS AppConfig and Azure App Configuration hold the same YAML as a file, and
add change control a bucket doesn't have. AppConfig validates a change before
it goes live, rolls it out gradually with a deployment strategy, and rolls it
back automatically on a CloudWatch alarm. App Configuration keeps a label per
environment, a revision history and snapshots, so a bad edit can be restored
to an earlier value. Each team's file is one profile or key,
and extends: works between them:
# appconfig://opssignal/prod/payments
extends: default # the "default" profile of the same application and environment
# azappconfig://ops-config/opssignal/teams/payments?label=prod
extends: ../default # key opssignal/default, same store and label
| AWS AppConfig | Azure App Configuration | |
|---|---|---|
| URL | appconfig://<application>/<environment>/<profile> (names or IDs) |
azappconfig://<store>/<key>[?label=<label>] |
| Credentials | The AWS chain above | Workload identity, service principal or managed identity, as above |
| Permissions | appconfig:StartConfigurationSession, appconfig:GetLatestConfiguration |
App Configuration Data Reader |
| Endpoint override | AWS_ENDPOINT_URL_APPCONFIGDATA |
AZURE_APPCONFIG_ENDPOINT, with {store} in it, for sovereign clouds |
What polling costs. AppConfig bills for each configuration it sends, so
OpsSignal keeps its polling session between checks: an unchanged profile
returns an empty, unbilled answer. App Configuration is asked with the last
ETag, so an unchanged key costs a request but no download. Each check is
still one request per file per process, which matters on App Configuration's
request quotas when thousands of pods poll: raise
SIGNAL_CONFIG_REFRESH_SECS there if needed.
Cache and fallback. After every successful fetch, the raw file is cached on
local disk, before ${VAR} substitution, so no secret is ever written there.
When the source can't be reached, times out (10 seconds), or returns a file
that fails to load, OpsSignal uses the last good cached copy. If there is no
cached copy either, it starts on the zero-config default (console only) and
logs why, so a bucket outage never stops a service from starting or sending
signals. It switches to the real config as soon as the source is back.
Databases¶
Use a database when teams change their own routing through an internal UI or API instead of pull requests: the tool writes each team's YAML into one item, and OpsSignal reads it like a file. Writes, change history (DynamoDB Streams, the Cosmos DB change feed, Firestore triggers) and access control stay in the database. OpsSignal only ever reads.
Each item holds the whole YAML of one file in a single string attribute,
config by default:
| DynamoDB | Firestore | Cosmos DB | |
|---|---|---|---|
| URL | dynamodb://<table>/<id> |
firestore://<project>/<collection>/<document> (nested paths allowed) |
cosmos://<account>/<database>/<container>/<id> |
| Item lookup | String partition key id (?key= to rename); tables with a sort key are not supported |
Document path; ?database= for a non-default database |
Item id; partition key value is the id, or ?pk= |
| YAML attribute | config (?value= to rename) |
string field config (?field=) |
string property config (?field=) |
| Permissions | dynamodb:GetItem |
datastore.entities.get (Cloud Datastore Viewer) |
Cosmos DB Built-in Data Reader (data-plane RBAC; account keys are not used) |
| Endpoint override | AWS_ENDPOINT_URL_DYNAMODB (DynamoDB Local) |
FIRESTORE_EMULATOR_HOST |
AZURE_COSMOS_ENDPOINT, with {account} in it |
extends: names another item of the same table, database or container:
# dynamodb://opssignal-config/payments
extends: default # item "default" of the same table
In Firestore a relative entry resolves like a path (../shared/db-alerts from
opssignal/payments). In Cosmos DB the parent's partition key value is taken
to be its id; use a full cosmos:// URL with ?pk= otherwise.
Each check is one read per file per process: an eventually consistent
GetItem (half a read unit), one Firestore document read, or one Cosmos DB
point read (sent with the last ETag, so an unchanged item is a 304).
Live reload¶
The config is re-read every 60 seconds and changes are applied without a restart, for local files and remote sources alike. A change replaces the routes and providers as one unit, so no signal sees new routes with old providers.
A reload that can't be fetched or fails to load is skipped and logged, and the current config stays in place. A broken edit can't take alerting down.
Changes to storage are not applied live, because reopening the sink under
traffic could lose writes. They are logged and take effect on the next
restart.
| Variable | Default | Effect |
|---|---|---|
SIGNAL_CONFIG_REFRESH_SECS |
60 |
Seconds between checks. 0 loads once and never reloads. |
SIGNAL_CONFIG_CACHE_DIR |
$XDG_CACHE_HOME/opssignal, else ~/.cache/opssignal |
Where remote configs are cached. |
The CLI sends one signal and exits, so it loads the config once.
See Kubernetes for a ConfigMap and Secret setup that uses live reload.
Layered configs¶
Large organisations rarely keep every route in one file. OpsSignal lets a file build on others, so a platform team can own a default and each team adds only what is its own:
# default.yaml, owned by the platform team
providers:
slack:
webhook_url: "${ORG_SLACK_WEBHOOK}"
routes:
- match: { severity: [error, critical] }
providers: [slack]
storage:
path: /var/lib/opssignal/signals.db
# teams/payments.yaml
extends: ../default.yaml
providers:
webhook:
url: "${PAYMENTS_PAGER_URL}"
routes:
- match: { source: payments, severity: [critical] }
providers: [webhook, slack]
Point SIGNAL_CONFIG at the team file and the default comes with it.
There are two ways to combine files, and they can be mixed:
extends:in a file names one parent, or a list of them. A relative entry is resolved against the file's own location: the same directory, or the same bucket or container fors3://,gs://andaz://, or the same site forhttps://. A URL or an absolute path is used as is.- A list in
SIGNAL_CONFIG, such asSIGNAL_CONFIG=teams/payments.yaml,shared/db-alerts.yaml. Use this when a service needs alerts that several teams share, without one team file having to name another.
Files are merged lowest priority first: parents before the file that extends them, and earlier entries of a list before later ones. A file reached more than once, such as a default that every team extends, is loaded once.
| Section | How it merges |
|---|---|
providers |
By name. A later file's slack, webhook, pagerduty, teams or console replaces the earlier one whole. |
routes |
Added together. A later file's routes go first, so the more specific file is matched first and its providers are tried first, while the inherited routes still catch everything else. |
storage |
Taken from the last file that sets it. |
Because a failover route stops at the first provider that succeeds (see
delivery modes), a team route that matches a signal
effectively decides where it goes, and the inherited failover providers become
its fallbacks. An inherited fanout route still delivers to all of its
providers. Routes are
checked after merging, so a team file can route to a provider that only its
parent configures. A route naming an unknown provider is reported with the
file it is in, such as teams/payments.yaml: routes[0] references provider
'pagerduty'.
Rules that keep chains manageable:
- A cycle (
a.yamlextendsb.yaml, which extendsa.yaml) is a load-time error that shows the loop. - A chain can be at most 8 files deep and load at most 64 files.
- A remote file can extend other remote files but not local paths, so a file served over the network can never read the host's disk. A local file can extend a remote one, such as a team ConfigMap that extends a default in a bucket.
Every file in the chain is reloaded and cached on its own. An edit to the default reaches every team on the next check. When a remote file can't be fetched, its cached copy is used; if the fresh files load but no longer fit together (a team routes to a provider someone just removed from the default), the cached copies of the whole chain are used instead. Only if neither works does OpsSignal start on the zero-config default. A missing or broken local file in the chain stops loading, as a single local file does.
Example¶
providers:
slack:
webhook_url: "${SLACK_WEBHOOK_URL}"
webhook:
url: "https://ops.example.com/hooks/opssignal"
routes:
- match:
severity: [error, critical]
environment: prod
providers: [slack]
- match:
source: ci
providers: [webhook]
storage:
sink: sqlite
path: "./signals.db"
signal.yaml is gitignored in this repository. Commit only files that use
${VAR} placeholders for secrets, like
python/examples/signal.example.yaml.
providers¶
| Key | Fields | Notes |
|---|---|---|
console |
enabled (optional) |
Always registered, because it is the routing fallback. It cannot be turned off. |
slack |
webhook_url |
Slack incoming-webhook URL. |
webhook |
url |
Receives the full signal as a JSON POST. |
pagerduty |
routing_key, url (optional) |
Triggers a PagerDuty alert through the Events API v2. url defaults to the US endpoint; EU accounts set https://events.eu.pagerduty.com/v2/enqueue. |
teams |
webhook_url |
Microsoft Teams Workflows (or legacy incoming) webhook URL. Posts an Adaptive Card. |
Providers describes what each one sends and how to get its URL or key.
Added in v1.0.0-beta.2
Any provider can also set template, which replaces parts of the
message it sends; see Message templates.
Unknown provider keys are rejected, so a typo such as slak: fails at load
time instead of silently dropping a provider.
routes¶
Each route has a match block and a list of providers. Every condition in
match must hold; an omitted condition matches anything.
| Field | Type | Matches when |
|---|---|---|
severity |
list of info, success, warning, error, critical |
the signal's severity is in the list |
environment |
string | the signal's environment equals it |
source |
string | the signal's source equals it |
A route can also set mode, which decides what its list of providers means:
| Field | Values | Default |
|---|---|---|
mode |
failover, fanout |
failover |
Delivery modes¶
Added in v1.0.0-beta.2
mode: fanout. Before it, every route behaved as failover.
failover (the default): the providers are tried in order and delivery
stops at the first one that succeeds. Each provider gets up to 3 attempts
with exponential backoff before the next one is tried. Later providers are
fallbacks, not extra copies.
fanout: the signal goes to every provider in the list at the same
time. Each provider is retried on its own (up to 3 attempts), so a failing
PagerDuty never causes a second Slack message, and a slow provider never
holds up the others.
routes:
# Page on-call and tell the channel, every time.
- match:
severity: [critical]
environment: prod
providers: [pagerduty, slack]
mode: fanout
# Everything else: Teams, or email if Teams is down.
- match: {}
providers: [teams, email]
When several routes match one signal:
- each provider of a matching
fanoutroute is delivered to on its own; - the providers of all matching
failoverroutes form one ordered list (in file order), which is delivered to alongside the fan-out providers and stops at its first success, exactly as beforefanoutexisted; - a provider named by more than one matching route is used once, at its first position, so it never receives the same signal twice.
In the example above, a critical prod signal goes to PagerDuty, Slack, and Teams (falling back to email); any other signal goes to Teams, falling back to email.
The outcome is delivered when every fan-out provider and the failover list
succeeded, failed when none did, and partial when some did. The sink keeps
each provider's result too; see storage.
If no route matches, the list is just console. That fallback is built into
the routing engine and cannot be configured away.
Dedup and rate limits¶
Added in v1.0.0-beta.2
Routes can set dedup and rate_limit.
A failing job that retries every minute, or one bad deploy that breaks 200 DAGs, should page once, not hundreds of times. Two optional route fields hold back the repeats:
| Field | Value | Effect |
|---|---|---|
dedup |
a window: 30s, 10m, 2h, 1d (a bare number is seconds) |
The route sends each dedup key at most once per window. Repeats inside the window are suppressed. |
rate_limit |
{max: N, per: <window>} |
The route sends at most N signals in any sliding per window, whatever their key. Signals over the limit are suppressed. |
routes:
# One page per incident every 30 minutes, and never more than 10 pages
# an hour even if every DAG fails at once.
- match:
severity: [critical]
environment: prod
providers: [pagerduty, slack]
mode: fanout
dedup: 30m
rate_limit:
max: 10
per: 1h
# The channel still gets every warning, unthrottled.
- match:
severity: [warning, error]
providers: [slack]
How they behave:
- Per route. A suppressed route adds none of its providers for that
signal; other matching routes still send. To page once but keep every
occurrence in a channel, give the paging route a
dedupand add an unthrottled route for the channel. - The window starts at the last send, not the last repeat, so a
long-running incident re-alerts once per
dedupwindow as a reminder. - Only sends count. A suppressed signal does not use up the
rate_limit, and a signal that no provider delivered (statusfailed) is taken back, so the next occurrence is tried again instead of being suppressed as a duplicate of an alert nobody received. - Suppressed signals are still recorded in the sink with status
skippedand the reason inerror, for exampleroute [pagerduty, slack]: duplicate of a signal sent 4m ago (dedup 30m). If a signal was suppressed on one route and delivered on another, its status is the delivered one. - Shared by every process on the host that uses the same
storage.path: the history is kept in that SQLite file, so a CLI run from cron, each Airflow task process and a restarted service all see the same windows. Hosts do not share it with each other. - Editing a route (its match, providers, mode or limits) starts its history afresh.
- If the history can't be read (for example the file is locked by another process for more than 2 seconds), the signal is sent. Throttling never costs you an alert.
A signal no route matches still goes to console; that fallback is never
suppressed.
Dedup keys¶
Each signal's dedup key is the one the caller passed (dedup_key= in Python,
dedup_key in the C JSON payload), or else a hash of its source,
event_type, title and environment. Severity and message are not part of
it, so a retry with a new error message is still the same incident. Pass your
own key, such as the DAG and task id, when the title changes between retries.
A route that names a provider not configured under providers is a load-time
error, as is a route with an empty providers list, an unknown mode, a
dedup or rate_limit of zero, or an unknown key (so a typo such as
mod: fanout fails at load time instead of leaving the route on failover).
Unknown keys inside match are rejected too, so a typo such as
enviroment: prod fails at load time instead of matching every signal.
storage¶
| Field | Default | Notes |
|---|---|---|
sink |
sqlite |
Only sqlite exists today. Postgres is on the roadmap. |
path |
./signals.db |
Created if missing. |
The SQLite file has two tables. signals holds one row per signal, with its
overall delivery_status (pending, then delivered, partial or failed)
and provider, the comma-separated providers that delivered it.
deliveries holds one row per signal and provider, keyed by signal_id and
provider: pending before the provider's first attempt, then delivered or
failed, with its error and attempts. A process that exits in the middle
of a fan-out therefore still shows which providers already have the signal.
Databases created by earlier versions get the deliveries table the next time
they are opened.
A third table, throttle, holds the recent sends of routes that set
dedup or rate_limit. Rows older than a route's
windows are deleted as it sends. Signals those settings held back are stored
in signals with status skipped.
Every signal is written to the sink with its delivery status (delivered or
failed), the provider that delivered it, and any error.
Environment variables in values¶
${NAME} anywhere in a string value is replaced with the value of the
environment variable NAME. Several placeholders can appear in one value.
- An unset variable is a load-time error that names the variable. It is never silently replaced with an empty string.
- Substitution happens after YAML parsing, so secrets that contain quotes, colons or newlines can't break the file's structure.
Secrets from a secret store¶
A placeholder whose name has a colon reads a secret store instead of the environment. It works in any file of the chain, local or remote:
providers:
slack:
webhook_url: "${aws-sm:prod/opssignal#slack_url}"
webhook:
url: "${azure-kv:ops-vault/pagerduty-hook}"
| Placeholder | Reads | Needs |
|---|---|---|
${aws-sm:<name or ARN>} |
AWS Secrets Manager, current version | opssignal[aws] |
${gcp-sm:<project>/<secret>[/<version>]} |
GCP Secret Manager, latest unless pinned |
opssignal[gcp] |
${azure-kv:<vault>/<name>[/<version>]} |
Azure Key Vault, current version unless pinned | opssignal[azure] |
#fieldat the end reads one field of a JSON secret, so one secret can hold several values (${aws-sm:prod/opssignal#slack_url}). Fields of one secret share one read.- A full ARN is read in its own region; a name is read in
AWS_REGION.gcp-sm:also takes the fullprojects/<p>/secrets/<s>/versions/<v>form. - Credentials are the same chains as the cloud's config sources: IRSA, ECS and
instance roles on AWS; workload identity,
GOOGLE_APPLICATION_CREDENTIALSand the metadata server on GCP; workload identity, a service principal and managed identity on Azure. The identity needs read access to the secret only (secretsmanager:GetSecretValue,roles/secretmanager.secretAccessor, or the Key Vault Secrets User role). - Endpoint overrides, for private endpoints, sovereign clouds and emulators:
AWS_ENDPOINT_URL_SECRETSMANAGER,GOOGLE_SECRET_MANAGER_ENDPOINT, andAZURE_KEYVAULT_ENDPOINTwith{vault}in it.
Rotation. Each reload checks the secrets as well as the files, so a rotated
secret takes effect without a restart, within SIGNAL_SECRETS_TTL_SECS plus
one reload interval.
Cost. Every store bills per read (about $0.05 per 10,000 on AWS, $0.03 on
GCP and Azure). Values are kept in memory for SIGNAL_SECRETS_TTL_SECS, 300 by
default, so each process reads each secret about 8,600 times a month instead of
43,000 at the 60-second reload interval. 0 reads on every reload.
Failures. A malformed reference, an unknown store, or a store whose extra isn't installed stops startup like any other config mistake. A store that can't be reached is treated like an unreachable remote file: at startup the client runs on the default config (console only) and picks the file up once the store answers; on reload the current config stays. Values are only held in memory. The config cache stores the file with its placeholders, never the secrets.
Load-time errors¶
These all stop the config from loading, and the message says which rule failed:
- invalid YAML
- an unknown key anywhere in the file
- an unset
${VAR}, or an unterminated or empty${}placeholder - a
${store:...}secret reference that is malformed, names an unknown store, or needs an extra that isn't installed - a route that references an unconfigured provider, or lists no providers
- a provider
templatewith a syntax error, a part that provider doesn't have, or an unknown variable (see Message templates) - an
extendsthat is not a path or a list of paths, forms a cycle, goes more than 8 files deep, or (from a remote file) names a local path SIGNAL_CONFIGpointing at a path that is not a file, or using an unsupported URL schemeSIGNAL_CONFIG_REFRESH_SECSthat is not a whole number- a cloud URL whose support isn't installed (see Installing cloud support)
A remote source that can't be fetched is not on this list: it falls back as described in Remote configs.