Skip to content

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.

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.

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.

ClassSeverityDefault channelsTriggered by
IoTTelemetryThresholdAlertNotificationTypeWarningEmail, PushTelemetryThresholdExceededEto
IoTDeviceOfflineNotificationTypeFatalEmail, Push, SMSDeviceOfflineDetectedEto

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.

Every alert publish goes through IAlertThrottle (from Granit.Notifications) before reaching INotificationPublisher. The throttle is per (tenantId, notificationType, deduplicationKey) with a configurable window:

KeyDefaultPurpose
IoT:NotificationThrottleMinutes15Minutes 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.

KeyDefaultCascadePurpose
IoT:TelemetryRetentionDays365T → GRetention window enforced by StaleTelemetryPurgeJob
IoT:HeartbeatTimeoutMinutes15T → GOffline detection threshold
IoT:HeartbeatOfflineNotificationCacheMinutes60T → GTTL of the offline tracker, preventing alert spam
IoT:NotificationThrottleMinutes15T → GSame-alert throttle window
IoT:IngestRateLimit(set in rate-limit policy)T → GPer-tenant ingest rate (used by Granit.RateLimiting)
IoT:Threshold:{metricName}(unset)T → GPer-metric threshold (e.g. IoT:Threshold:temperature = 28.5)

All keys are read through ISettingProvider.GetOrNullAsync() — lookup cost is ~µs after FusionCache warmup.

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.

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.

MetricTagsFires when
granit.iot.ingestion.threshold_exceededtenant_id, metric_nameThreshold evaluator flagged a breach
granit.iot.alerts.throttledtenant_id, metric_nameThrottle suppressed a duplicate alert
granit.iot.device.offline_detectedtenant_idHeartbeat 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”.