Signals
signalsSay what the app noticed as one typed, signed, short-lived signal that the app, its other instances and its playbooks hear, with dedupe, a ring of recent signals and no personal data.
signalsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
signals is how the parts of your app tell each other what they noticed, as one small typed envelope that your handlers and playbooks hear. It is free and needs no service.
Each signal has a name, a severity, a subject, a short life and a signature when it crosses instances. Handlers hear it off the request path. A repeat of a live signal folds into the first (its count goes up) instead of making noise. Built-in names cover resources under pressure, a dependency down, an error budget burning, a deploy and an anomaly.
At a glance
signals:
keep: 1000
max-per-minute: 600
key: secret:MAINSDK_SIGNAL_KEYimport { signals } from "mainsdk";
// one signal per condition, however often it is emitted while live
await signals.emit("app.import_failed", { severity: "high", subject: "import:nightly", data: { rows: 3 } });
signals.on("pressure.*", (s) => console.log(s.name, s.severity, s.trust));Drivers
1 drivers. Select one to see its line in main.yaml.
signals:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SIGNAL-0006 | Checkmainsdk check, before deploy | A signals value is invalid: keep outside 100 to 10,000, a TTL over ttl.max or over 24h, or key written as a literal instead of secret:NAME |
MAIN-SIGNAL-0007 | Checkmainsdk check, before deploy | Production uses a shared broadcast driver (redis) and app-scope signals, with no signals.key; another app on the same store could forge them |
MAIN-SIGNAL-0001 | RuntimeError responses and logs | See the spec |
MAIN-SIGNAL-0002 | RuntimeError responses and logs | See the spec |
MAIN-SIGNAL-0003 | RuntimeError responses and logs | See the spec |
MAIN-SIGNAL-0004 | RuntimeError responses and logs | See the spec |
MAIN-SIGNAL-0005 | RuntimeError responses and logs | See the spec |
MAIN-SIGNAL-0008 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| The mainsdk-signal@1 envelope and the built-in catalogue | Phase 1 | 0.4 | signals#envelope |
| emit, on and list with dedupe and TTL | Phase 1 | 0.4 | signals#bus |
| App-scope signals across instances | Phase 1 | 0.4 | signals#app-scope |
| Built-in emitters from resources and health | Phase 1 | 0.4 | signals#emitters |
| Emitters for security events | Phase 1 | 0.4 | signals#emitters-security |
| mainsdk signals | Phase 1 | 0.4 | signals#cli |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Signals correlated across machines and one response across apps (spec fleet-defend) |
Quick start
app/jobs/import.ts
import { signals } from "mainsdk";
// one signal per condition, however often it is emitted while live
await signals.emit("app.import_failed", { severity: "high", subject: "import:nightly", data: { rows: 3 } });
signals.on("pressure.*", (s) => console.log(s.name, s.severity, s.trust), { min: "medium" });use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
app.signals().emit("app.order.paid", "info", serde_json::json!({ "n": 1 })).await?;
assert_eq!(app.signals().list().await?.len(), 1);
Ok::<_, MainError>(())
}).await?;Configuration
signals:
keep: 1000 # recent signals held per instance, 100 to 10,000
max-per-minute: 600 # per source; over it, emits are dropped and counted
key: secret:MAINSDK_SIGNAL_KEY # HMAC key for app-scope signals across instances
ttl: { default: 5m, max: 24h }Without key, app-scope signals go unsigned. With a shared broadcast driver (redis) in production, mainsdk check --env prod reports MAIN-SIGNAL-0007 until you set it, because another app on the same store could forge them.
Calls
| Call | What it does |
|---|---|
signals.emit(name, { severity, subject?, data?, ttl?, scope? }) | Emit an app.* signal. scope is instance (default) or app (every instance of this app). Throws MAIN-SIGNAL-0001 on a bad name, severity or data over 4KB. |
signals.on(pattern, handler, { min?, from? }) | Hear signals by name, a prefix.* glob or *. min filters by severity and from by trust (self, app, peer, host). Returns an unsubscribe. |
signals.list({ name?, min?, live?, cursor?, limit? }) | The recent-signal ring, newest first, as { items, next }. |
Only MainSDK emits the built-in names (pressure.high, dependency.degraded, slo.burning, budget.low, anomaly.detected, posture.changed and others). Your names start with app..
What you get
- Signs with HMAC-SHA256 over canonical JSON (RFC 8785) when
keyis set, and drops an app-scope signal with no signature or a bad one (MAIN-SIGNAL-0004). - Redacts
datalike a log record: labelled personal fields, resolved secrets and key formats are removed, and the attribute is named inMAIN-SIGNAL-0002. - Folds a repeat of a live signal into the first, and delivers again only when the severity rises.
- Runs handlers off the request path, one at a time per handler, at most 1s each (
MAIN-SIGNAL-0005), with a queue of 1,000 (MAIN-SIGNAL-0008). - Costs under 5 microseconds at p99 per emit with no handler.
- Writes each signal to the log, so
mainsdk signalsreads it from the local event store.
When the app has a security-event feed (securityEvents, the shape of security.on), detections at high or critical become attack.suspected (subject net:<trimmed network> or actor:<keyed hash>, never a raw id or address), secret.rotated becomes key.rotated (subject secret:<NAME>, never the value) and incident.control becomes incident.control (subject incident:<target>). With no feed these three stay unused.
A single serverless app gets instance-scope signals within one invocation only.
Command line
npx mainsdk signals --min medium # newest first, with count and age
npx mainsdk signals --name "pressure.*" --json
npx mainsdk signals --follow # keep printing new onesHelpers for wiring and tests
| Call | What it does |
|---|---|
new Signals(opts) | The signal bus of one app instance, for tests and custom wiring; apps use signals.emit. |
parseSignals(section) | Read the signals section of main.yaml into settings. |
signalsIssues(section) | List what is wrong in the signals section, without throwing. |
signalEnvelopeSchema(const) | The JSON Schema of a signal envelope. |
Errors and checks
MAIN-SIGNAL-0001:emitrefused (name, severity or data).MAIN-SIGNAL-0002: a data attribute was dropped as personal or secret.MAIN-SIGNAL-0003: a source passedmax-per-minute; the rest were dropped.MAIN-SIGNAL-0004: an envelope failed verification (unsigned, bad signature, unknown key, stale or futureat).MAIN-SIGNAL-0005: a handler threw or ran over 1s.MAIN-SIGNAL-0006: asignalsvalue is invalid (mainsdk check).MAIN-SIGNAL-0007: production shares a broadcast with app-scope signals and has nosignals.key(mainsdk check).MAIN-SIGNAL-0008: a handler's queue was full; the oldest were dropped.
Each code's cause and fix is in check codes.
Swapping drivers
signals has no driver of its own. Instance-scope signals stay in the process. App-scope signals ride the app's events.broadcast driver, so changing that driver in main.yaml changes how instances hear each other. With a shared driver (redis) in production, set signals.key (MAIN-SIGNAL-0007). A broadcast outage leaves instance-scope signals working, and a dropped broadcast is not retried.
Testing
createTestApp() from the Testing kit runs signals offline with the fake clock and the local broadcast driver, so a test can emit, advance time past a ttl and read the ring with signals.list. Build a bus directly with new Signals(opts) when you need custom wiring.
Next
- Playbooks act on signals.
- Norms record what the app has learned from them.
- Neighbours carry signals between apps on one machine.
- Log holds each signal as a record.
Was this page helpful?