Skip to main content

deadband

Forwards a numeric reading only when it has changed meaningfully since the last forwarded value.

# Config fields, showing default values
pipeline:
processors:
- label: ""
deadband:
single:
value_source: "" # No default (optional)
value_field: "" # No default (optional)
tag_source: "" # No default (optional)
tag_field: "" # No default (optional)
metrics:
path: "" # No default (optional)
name_field: "" # No default (optional)
value_field: "" # No default (optional)
default:
mode: "absolute"
threshold: 0
heartbeat_interval: "0s"
max_slew: 0
hold_duration: "0s"
tag_overrides: {}
on_error: "pass"
max_tags: 100000
timestamp_source: "" # No default (optional)
timestamp_field: "" # No default (optional)

Deadband filter ("report by exception") for repetitive or noisy numeric streams — IoT/SCADA tags, application gauges, sampled metrics. State is kept per tag: each new reading is compared against the last value FORWARDED for that tag (not the last seen), so slow sub-threshold drift still eventually crosses the band and is forwarded.

Pick exactly one source shape. "single" handles one reading plus a tag id per message and drops or forwards the whole message. "metrics" handles a message carrying an array of {name, value} objects and rewrites the payload with the sub-threshold metrics removed.

A reading is forwarded when ANY of the configured criteria fire: the band is exceeded (absolute or percent), the keep-alive interval has elapsed, or the rate of change exceeds max_slew. The first reading seen for a tag is always forwarded. Tune the band globally via "default" and per tag via "tag_overrides".

Set "hold_duration" to debounce a noisy signal: a band crossing must then stay outside the band for that long before it is forwarded, so a transient spike that immediately reverts is suppressed. The keep-alive interval and max_slew still forward immediately.

Because the decision depends on per-tag order, a pipeline using this processor runs single-threaded: the runtime pins pipeline.threads to 1 (overriding any threads setting) so messages are processed in order. This affects only pipelines that use deadband.

Fields

single

Single-value message shape. Mutually exclusive with metrics; set exactly one.

Type: object

single.value_source

Where the numeric value is read from: the JSON message body or message metadata. Defaults to body.

Type: string

Options: body, meta

single.value_field

Dot-path into the JSON body (e.g. value, payload.reading) or the metadata key holding the numeric value. Defaults to value.

Type: string

single.tag_source

Where the tag id is read from: the JSON message body or message metadata. Defaults to meta.

Type: string

Options: body, meta

single.tag_field

Dot-path into the JSON body or the metadata key holding the tag id. The tag id keys per-tag state and per-tag overrides. Defaults to node_id.

Type: string

metrics

Multi-metric message shape. Mutually exclusive with single; set exactly one.

Type: object

metrics.path

Dot-path into the JSON body locating the array of metric objects. Defaults to metrics.

Type: string

metrics.name_field

Key within each metric object holding the tag id (metric name). Defaults to name.

Type: string

metrics.value_field

Key within each metric object holding the numeric value. Defaults to value.

Type: string

default

Band config applied to any tag without a matching per-tag override.

Type: object

default.mode

How the band is measured. "absolute": forward when |new - last| >= threshold. "percent": forward when |new - last| >= |last| * threshold/100.

Type: string
Default: "absolute"

Options: absolute, percent

default.threshold

Band width. 0 forwards on any change (dedup of identical consecutive values).

Type: float
Default: 0

default.heartbeat_interval

Keep-alive interval. If greater than zero, forward a reading when this much time has passed since the last forward for that tag, even when the value is within the band. "0s" disables.

Type: string
Default: "0s"

default.max_slew

Rate-of-change trigger in value units per second. If greater than zero, forward when |new - last| / dt is at or above this rate. 0 disables.

Type: float
Default: 0

default.hold_duration

Hold / debounce time. If greater than zero, a reading that crosses the band must stay outside the band for at least this long before it is forwarded, suppressing a transient spike that immediately reverts. The reading forwarded is the latest one once the hold elapses, not the one that first crossed. A reading that falls back within the band before the hold elapses clears the pending hold. heartbeat_interval and max_slew still forward immediately (they bypass the hold). Note a sustained, genuine change is also delayed by up to this duration; set heartbeat_interval below hold_duration if that delay matters. "0s" disables (a band crossing forwards on the first reading).

Type: string
Default: "0s"

tag_overrides

Per-tag band overrides keyed by tag id. Unset sub-fields inherit the default.

Type: map of object
Default: {}

tag_overrides.<name>.mode

Per-tag override of the band mode. Inherits the default when unset.

Type: string

Options: absolute, percent

tag_overrides.<name>.threshold

Per-tag override of the band width. Inherits the default when unset.

Type: float

tag_overrides.<name>.heartbeat_interval

Per-tag override of the keep-alive interval. Inherits the default when unset.

Type: string

tag_overrides.<name>.max_slew

Per-tag override of the rate-of-change trigger. Inherits the default when unset.

Type: float

tag_overrides.<name>.hold_duration

Per-tag override of the hold / debounce time. Inherits the default when unset.

Type: string

on_error

What to do when a reading is missing, non-numeric, or has no tag id. "pass" (default) logs and forwards the message unchanged; "drop" discards it; "fail" marks the message as failed.

Type: string
Default: "pass"

Options: pass, drop, fail

max_tags

Maximum number of distinct tags whose state is tracked. When exceeded, the least-recently-active tag is evicted (its next reading is treated as first-seen and forwarded, which also bypasses hold_duration). Bounds memory when tag ids come from message content. 0 means unlimited; only set 0 when the tag set is known to be bounded. In metrics mode a single message carrying more than max_tags distinct metric names will evict within that one message, so size max_tags to the per-message distinct-tag count, not just the long-run cardinality.

Type: int
Default: 100000

timestamp_source

Where the event timestamp is read from when timestamp_field is set: the JSON message body or message metadata. Defaults to body.

Type: string

Options: body, meta

timestamp_field

Optional dot-path into the JSON body (e.g. ts, payload.timestamp) or the metadata key holding the message's event time. When set, heartbeat_interval, max_slew, and hold_duration are measured against this event time instead of the wall clock, so replaying historical data deadbands as if the original time had elapsed. The value may be an RFC3339 / RFC3339Nano string (which must include a timezone offset, e.g. a trailing Z or +00:00; a zone-less or space-separated timestamp is not RFC3339 and reads as unparseable) or an epoch number (seconds, milliseconds, microseconds, or nanoseconds -- the unit is inferred from magnitude, so it must be a real epoch, not a packed calendar integer such as 20260102, which parses as a raw epoch and yields the wrong instant). A message whose timestamp is missing or unparseable is routed through on_error; note the default posture, pass, forwards it unfiltered, so a misconfigured timestamp field turns the filter into a pass-through rather than dropping data. In metrics mode this single message-level timestamp applies to every metric in the array (per-metric timestamps are not read). Because the timers track event time, a stalled or repeated timestamp (e.g. duplicate second-resolution stamps) stalls heartbeat_interval, max_slew, and hold_duration with it. Unset (the default) uses the wall clock.

Type: string

When to Use

The driving use case is SCADA / IIoT readiness — OPC-UA, Modbus, or Sparkplug B feeds that ship a reading every poll interval whether it changed or not. Nothing about the processor is SCADA-specific: any pipeline carrying numeric values (IoT tags, application gauges, sampled metrics, log-derived counters) can use it.

Use the deadband processor when you need to:

  • Cut bandwidth and storage on numeric streams where most readings repeat or barely move — sensors, gauges, counters polled on a fixed interval.
  • Convert a polled stream to a change-event stream — downstream consumers see one message per meaningful change instead of one per poll.
  • Catch fast movements early while still suppressing slow drift — combine a band threshold with a max_slew rate trigger.
  • Keep a keep-alive signal so downstream consumers can distinguish unchanged from dead — set heartbeat_interval to forward a reading at least every N seconds even when within the band.
  • Tune noise floors per tag — a noisy vibration sensor and a steady temperature sensor get different bands via tag_overrides.

Don't use this if:

  • You need to filter on non-numeric content (strings, categories, presence/absence) — use mapping with a meta deleted = ... pattern or a switch processor.
  • You need to debounce by sample count (forward only after N consecutive readings). Only time-based debounce is available, via hold_duration; a sample-count filter is not yet shipped.
  • You need to deduplicate identical full messages rather than per-tag numeric readings — use a cache + mapping pattern with a content hash.

How forwarding works

For each (tag, value), the processor evaluates the configured triggers in order and forwards the reading if any of them fires:

  1. First reading for the tag. A tag with no prior state is always forwarded; its value becomes the baseline.
  2. Heartbeat elapsed. If heartbeat_interval > 0 and at least that long has passed since the tag was last forwarded, the reading is forwarded — even when the value is within the band. The new value becomes the baseline.
  3. Rate of change exceeds max_slew. If max_slew > 0, |new − last| / dt (in value units per second, since the last forward) at or above max_slew forwards the reading. Catches a fast ramp before the band would catch it.
  4. Band exceeded. Absolute: |new − last| ≥ threshold. Percent: |new − last| ≥ |last| × threshold / 100. A delta of exactly zero is never meaningful — identical consecutive values are always deduped.

Behavioral guarantees:

  • Comparisons are against the last forwarded value, not the last seen value. Slow sub-threshold drift therefore accumulates and eventually crosses the band, instead of being suppressed forever.
  • Dropped readings do not update state. Only a forwarded reading resets the baseline value and timestamp.
  • Backward clock steps are safe. A negative dt makes the heartbeat check false and skips the slew check; only the band test applies until the clock recovers.
  • Non-finite values are rejected. NaN, +Inf, -Inf, and out-of-range JSON numbers route through on_error rather than poisoning the baseline; one bad reading would otherwise silently disable deadbanding for that tag forever.

Source shapes

single — one reading per message

The whole message is forwarded or dropped. This is the SCADA/IIoT "one tag per poll" shape — OPC-UA, Modbus, and most CSV-over-Kafka feeds emit messages like this. Tag ids typically come from message metadata (set by an earlier mapping step or by the input itself), and the numeric value lives at a known body path.

- deadband:
single:
value_field: value # body.value
tag_source: meta
tag_field: node_id # metadata.node_id
default:
mode: absolute
threshold: 0.5

Either field can be read from the body or from metadata — set value_source / tag_source independently. A numeric tag id (e.g. a JSON number) is coerced to its string form so it can key the state map.

metrics — array of metrics per message

A message carries an array of {name, value, ...} objects (Sparkplug B-style birth/death events, OpenTelemetry-style metric snapshots, batched gauge updates). Each metric is deadbanded independently. The output payload preserves the surrounding JSON shape but contains only metrics whose readings crossed their band; if every metric in the array was within its band, the whole message is dropped.

- deadband:
metrics:
path: metrics # body.metrics is the array
name_field: name
value_field: value
default:
mode: absolute
threshold: 1.0

Passthrough byte preservation. When no metric was dropped (every reading in the batch crossed its band), the processor forwards the original message bytes untouched rather than re-marshaling. This is deliberate: re-encoding can reorder object keys, which silently rewrites the payload, breaking verification of signatures attached upstream and any order-sensitive consumer.

When the array is partially filtered, the surrounding object is re-encoded with the filtered array spliced back in at path; the delivered bytes are then the re-encoded form, not the original ones. A signature attached before deadband stops verifying once partial filtering rewrites the body. If downstream consumers verify signatures, place the signature processor after deadband: the signature is then computed over the canonical form of the filtered payload the consumer actually receives.

Other paths

Both shapes accept dot-paths into nested JSON:

# Single value at a nested path:
- deadband:
single:
value_field: payload.reading
tag_source: meta
tag_field: sensor_id

# Metrics array at a nested path:
- deadband:
metrics:
path: payload.metrics
name_field: name
value_field: value

Paths address only objects at intermediate steps — an intermediate array or scalar produces a "not found" error that routes through on_error.

Band tuning

absolute mode

absolute is the default. Forward when |new − last| ≥ threshold. Best when the signal has a roughly known scale and a fixed noise floor — a temperature sensor reading °C with ±0.3°C jitter, set threshold: 0.5.

default:
mode: absolute
threshold: 0.5

percent mode

Forward when |new − last| ≥ |last| × threshold / 100. Useful when a signal spans many orders of magnitude (flow rates, pressures, counts) — a 1% band is meaningful at both 100 and 10 000.

default:
mode: percent
threshold: 5 # 5%
Percent thresholds are 0–100, not 0–1

The submission validator emits a warning when a percent threshold is > 100 because that almost always means the author wrote a fraction. 0.5 here means 0.5%, not 50%. To get 50%, write 50.

heartbeat_interval — distinguish dead from unchanged

A signal that has stopped reporting and a signal that is reporting the same value every poll look identical downstream once deadbanding drops the duplicates. Set heartbeat_interval to forward at least one reading per interval per tag regardless of value movement, so consumers can tell unchanged from dead.

default:
mode: absolute
threshold: 0.5
heartbeat_interval: 60s

A heartbeat-driven forward resets the baseline to the current value. Subsequent band comparisons measure against that last emitted sample, not against the last sample that crossed the band on its own.

max_slew — catch fast movements early

A wide band suppresses noise but also delays detection of a real ramp. max_slew adds an orthogonal trigger: forward when the rate of change since the last forward exceeds the configured rate, in value units per second.

default:
mode: absolute
threshold: 10 # noise floor
max_slew: 2.0 # ...but forward immediately on > 2 units/sec

max_slew is in value units per second, not per message. The rate is |new − last| / dt, where dt is the seconds elapsed since the last forward for that tag, on the wall clock by default or on the message's own event time when timestamp_field is set.

Per-tag overrides

tag_overrides is an object map keyed by tag id. Each entry is a partial band config; any sub-field the override does not set inherits from default.

default:
mode: absolute
threshold: 0.5
heartbeat_interval: 60s
tag_overrides:
vibration-1: # noisier sensor — wider band, faster heartbeat
threshold: 5.0
heartbeat_interval: 30s
counter-7: # this tag uses percent mode instead
mode: percent
threshold: 1
pressure-9: # only the slew differs
max_slew: 50

A tag_overrides entry with no recognized sub-fields is equivalent to no override at all for that tag.

Error handling

on_error controls what happens when a reading is missing, non-numeric, has no tag id, or the body cannot be parsed as a JSON object:

ValueBehavior
pass (default)Log the error and forward the message unchanged.
dropLog the error and silently drop the message (or the offending metric, in metrics mode).
failReturn a processor error so the message is routed by error handling (e.g. to a dead-letter queue).

In metrics mode, on_error applies per metric: a single malformed metric in a batch follows the configured posture, while the surrounding message and the rest of the batch are still processed normally.

State bounds

The processor keeps one state entry per distinct tag id seen. With a bounded physical tag set (the typical SCADA case), this is naturally small — a few hundred to a few thousand entries — and max_tags rarely matters. But when tag_source: body or metrics mode is used, the tag id comes from message content; a high-cardinality stream (or an adversarial one) could otherwise grow state without bound.

max_tags caps the state map:

  • Default 100000 — a generous backstop for almost any real workload.
  • 0 means unlimited. Only safe when the tag set is known to be bounded.
  • When the cap is reached, the least-recently-active tag is evicted before a new one is tracked. Activity means a forward or a hold-candidate transition, not a read: a tag whose readings all stay inside its band does not refresh its position, so it ages as though idle. An evicted tag's next reading is treated as first-seen and forwarded, which also bypasses hold_duration — the conservative choice. The processor logs one WARN the first time it evicts, with the configured cap; subsequent evictions are silent.

If you see the eviction warning, either raise max_tags or use a more constrained tag source (move tag ids into metadata that you set, rather than reading them from untrusted body content).

Worked example

A sensor reports temperature once a second. Below, with threshold: 0.5 and heartbeat_interval: 60s:

Time (s)ReadingForwarded?Why
021.5First reading
121.5Δ = 0, identical
221.7Δ = 0.2 < 0.5
321.8Δ = 0.3 < 0.5 (still measuring against last forwarded 21.5)
422.0Δ = 0.5 ≥ 0.5 — new baseline is 22.0
5–6322.0 ± 0.1All within [21.5, 22.5]
6422.1Heartbeat: 60s since last forward — new baseline is 22.1
6522.1Δ = 0

Notice the drift catch at t=4: even though no single reading moved by 0.5, the cumulative drift from the last forwarded value did, so the band fired. This is the property that makes deadbanding safe — slow drift is not suppressed forever.

  • Worked examples → — five end-to-end recipes: simple absolute band, Sparkplug B metrics array, percent band for wide-magnitude signals, ramp detection via max_slew, and a SCADA-shaped pipeline with per-tag overrides.
  • signature processor — pair after deadband so signatures cover the deadbanded output, not the dropped originals.
  • metadata processor — set tag ids in metadata declaratively so deadband can read them via tag_source: meta.
  • Pipeline error handling — route messages that on_error: fail rejects to a dead-letter sink.
  • mapping / Bloblang guide — when a band rule is not enough and you need conditional or computed filtering.

Limitations

  • No sample-count debounce. Time-based debounce ships via hold_duration (a band crossing must stay out of band for the configured time before it is forwarded), but there is no "hold for N consecutive readings" filter.
  • State is per-process, in-memory. Restarting the edge resets every tag's baseline; the next reading per tag will be forwarded as first-seen. The processor does not currently persist state across restarts.
  • Eviction ages tags by forwards, not by reads. When max_tags is exceeded the least-recently-active tag is evicted, and only forwards and hold-candidate transitions count as activity. A tag reporting steadily within its band can therefore be evicted while noisier tags stay tracked.
  • Event time is opt-in and message-level. By default the timers run on the edge node's wall clock. Set timestamp_field to measure heartbeat_interval, max_slew, and hold_duration against each message's own event time instead, which is what makes replaying historical data deadband as if the original time had elapsed. Two limits remain: in metrics mode the single message-level timestamp applies to every metric in the array (per-metric timestamps are not read), and a stalled or repeated timestamp stalls the timers with it.
  • Numeric values only. Strings, booleans, and structured types are not numeric and route through on_error. Numeric strings ("21.5") read from metadata are parsed; numeric strings in the body are not.