IoT Notifications Bridge
Granit.IoT.Notifications is the 15-line bridge between Ring 2’s
integration events and Granit.Notifications. Threshold breaches and
offline detections become typed notifications the rest of your app already
understands — same admin UI, same channel matrix, same throttle primitives.
What problem does this bridge solve?
Section titled “What problem does this bridge solve?”IoT alerts repeatedly end up in a silo:
- A dedicated IoT inbox. The operations team watches one UI for device alerts, another for billing, another for support tickets. Critical alerts get lost in the split.
- Duplicate notification infrastructure. Teams ship an “IoT alert email service” next to the app’s existing notification pipeline, doubling the SMTP integration and the bounce handling.
- Alert storms on flaky hardware. A sensor dancing around its threshold publishes an alert every 10 seconds. The notification channel throttles itself (or the customer throttles the app).
- No per-tenant personalisation. “My warehouse is hot, ship alerts by SMS” is impossible without a tenant-override mechanism.
The bridge closes all four by mapping IoT ETOs to INotificationPublisher
invocations, honouring per-tenant settings, and throttling via
IAlertThrottle — the same primitives every other Granit module uses.
How it fits in the pipeline
Section titled “How it fits in the pipeline”flowchart LR
TH["TelemetryIngestedHandler<br/>(Granit.IoT.Wolverine)"] --> EX{"Threshold<br/>exceeded?"}
EX -- yes --> E1["TelemetryThresholdExceededEto"]
HB["DeviceHeartbeatTimeoutJob<br/>(Granit.IoT.BackgroundJobs)"] --> E2["DeviceOfflineDetectedEto"]
E1 --> B1["TelemetryThresholdNotificationHandler"]
E2 --> B2["DeviceOfflineNotificationHandler"]
B1 --> TH1{"IAlertThrottle"}
B2 --> TH2{"IAlertThrottle"}
TH1 -- pass --> P["INotificationPublisher"]
TH2 -- pass --> P
TH1 -- throttled --> M1["granit.iot.alerts.throttled"]
TH2 -- throttled --> M1
P --> CH["Granit.Notifications<br/>Email / Push / SMS"]
The IoT module publishes ETOs; the bridge handlers translate them into notifications the rest of the app already understands.
The two notification types
Section titled “The two notification types”| Class | Severity | Default channels | Triggered by |
|---|---|---|---|
IoTTelemetryThresholdAlertNotificationType | Warning | Email, Push | TelemetryThresholdExceededEto |
IoTDeviceOfflineNotificationType | Fatal | Email, Push, SMS | DeviceOfflineDetectedEto |
Both are registered by GranitIoTNotificationsModule via
INotificationDefinitionProvider — they show up automatically in the
Granit.Notifications admin UI, where end users configure channels and
override per recipient.
Alert throttling
Section titled “Alert throttling”Every alert publish goes through IAlertThrottle (from
Granit.Notifications) before reaching INotificationPublisher. The
throttle is per (tenantId, notificationType, deduplicationKey) with a
configurable window:
| Key | Default | Purpose |
|---|---|---|
IoT:NotificationThrottleMinutes | 15 | Minutes between repeat alerts for the same (device, metric) pair |
A second breach of the same metric within the window increments
granit.iot.alerts.throttled instead of paging the on-call.
Per-tenant configuration via IoTSettingNames
Section titled “Per-tenant configuration via IoTSettingNames”All IoT settings are registered by IoTSettingDefinitionProvider with
providers ["T", "G"] (Tenant + Global cascade) — a tenant admin can
override any key without code changes.
| Key | Default | Cascade | Purpose |
|---|---|---|---|
IoT:TelemetryRetentionDays | 365 | T → G | Retention window enforced by StaleTelemetryPurgeJob |
IoT:HeartbeatTimeoutMinutes | 15 | T → G | Offline detection threshold |
IoT:HeartbeatOfflineNotificationCacheMinutes | 60 | T → G | TTL of the offline tracker, preventing alert spam |
IoT:NotificationThrottleMinutes | 15 | T → G | Same-alert throttle window |
IoT:IngestRateLimit | (set in rate-limit policy) | T → G | Per-tenant ingest rate (used by Granit.RateLimiting) |
IoT:Threshold:{metricName} | (unset) | T → G | Per-metric threshold (e.g. IoT:Threshold:temperature = 28.5) |
All keys are read through ISettingProvider.GetOrNullAsync() — lookup
cost is ~µs after FusionCache warmup.
Registration
Section titled “Registration”Bundled in Granit.Bundle.IoT:
builder.Services.AddGranit(builder.Configuration).AddIoT();Or standalone:
builder.Services.AddGranitIoTNotifications();GranitIoTNotificationsModule depends on GranitIoTModule and
GranitNotificationsModule. No further setup is required — the Wolverine
handlers are auto-discovered.
Wiring your own alert sources
Section titled “Wiring your own alert sources”Need to raise a custom alert that isn’t a threshold breach or offline
event? Publish your own IIntegrationEvent via
IDistributedEventBus.PublishAsync() and register a handler that maps it
to INotificationPublisher.PublishAsync(YourNotificationType.From(eto)).
The IoT bridges are the reference pattern — 15 lines apiece.
Observability
Section titled “Observability”| Metric | Tags | Fires when |
|---|---|---|
granit.iot.ingestion.threshold_exceeded | tenant_id, metric_name | Threshold evaluator flagged a breach |
granit.iot.alerts.throttled | tenant_id, metric_name | Throttle suppressed a duplicate alert |
granit.iot.device.offline_detected | tenant_id | Heartbeat job flagged first offline detection |
Granit.Notifications adds its own metrics on channel-level delivery
(granit.notifications.sent, granit.notifications.failed) — wire both
for a complete picture of “alert fired → customer notified”.
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”See also
Section titled “See also”- Telemetry ingestion — where
TelemetryThresholdExceededEtois raised - Operations — where
DeviceOfflineDetectedEtois raised - Timeline bridge — the other cross-cutting Ring 3 package