Skip to content

IoT Timeline Bridge

Granit.IoT.Timeline is a 15-line handler module that listens to device domain events and writes them as TimelineEntryType.SystemLog entries. Your support team, your compliance auditor, and your mobile app all see the same audit trail — no extra code, no second schema.

ISO 27001 asset traceability and GDPR audit logs both demand who did what, when, and why on every long-lived asset. Without a bridge, IoT events land in three different places:

  • In the database audit columns. CreatedBy / ModifiedBy tell you who last touched the row — not the full sequence of transitions.
  • In structured logs. Searchable, yes. But not joined to the device record in the admin UI, and rotated away after 30 days.
  • In nowhere at all. Suspend("sensor was decommissioned by accident") without a persistent audit entry means the next incident reviewer reinvents the history.

The Timeline bridge consolidates all three into the same activity-stream your other entities (invoices, parties, documents) already use.

flowchart LR
  E1["DeviceProvisionedEvent"] --> T["DeviceTimelineHandler"]
  E2["DeviceActivatedEvent"] --> T
  E3["DeviceSuspendedEvent"] --> T
  E4["DeviceReactivatedEvent"] --> T
  E5["DeviceDecommissionedEvent"] --> T
  T --> W["ITimelineWriter.PostEntryAsync"]
  W --> DB[("timeline_entries<br/>entityType = 'Device'")]
  DB --> UI["Admin UI<br/>device detail page"]
  DB --> API["GET /timeline/Device/{id}"]
Domain eventTimeline body
DeviceProvisionedEventDevice provisioned: {SerialNumber} (status = Provisioning)
DeviceActivatedEventDevice activated: {SerialNumber} (Provisioning → Active)
DeviceSuspendedEventDevice suspended (Active → Suspended): reason = '{Reason}'
DeviceReactivatedEventDevice reactivated: {SerialNumber} (Suspended → Active)
DeviceDecommissionedEventDevice decommissioned (status = Decommissioned)

Each entry carries entityType = "Device" and entityId = deviceId. The Timeline module resolves the authoring user from ICurrentUser automatically, so the audit trail is attributable to a real principal — not just “system”.

Bundled in Granit.Bundle.IoT:

builder.Services.AddGranit(builder.Configuration).AddIoT();

Or standalone:

builder.Services.AddGranitIoTTimeline();

GranitIoTTimelineModule depends on GranitIoTModule and GranitTimelineModule. The handlers are internal static partial — Wolverine auto-discovers them on startup, so no manual handler registration is needed.

Granit.Timeline.Endpoints exposes /api/timeline/{entityType}/{entityId} by default:

Terminal window
curl "https://your-app/api/timeline/Device/$DEVICE_ID" \
-H "Authorization: Bearer $TOKEN"

Response includes each system-log entry in chronological order — exactly what a support engineer needs when “device goes offline” turns into a support ticket.

RequirementMet by
ISO 27001 A.8.1.1 — asset inventory managementFull provenance trail per device (provision, activate, suspend, reactivate, decommission)
ISO 27001 A.12.4.1 — event loggingDevice lifecycle events logged in a tamper-resistant timeline, durable past log rotation
GDPR Art. 30 — records of processing activitiesTenant-scoped timeline entries let a DPO reconstruct who touched which device

Because Granit.Timeline entries are tenant-scoped and permission-checked, a tenant admin can grant auditors read-only access to their own device chatter without exposing any other tenant’s data.

Need to surface custom device events (firmware update, credential rotation, custom workflow transition) in the same timeline?

  1. Raise an IDomainEvent from your code.

  2. Add a handler method in your own internal static partial class:

    public static Task HandleAsync(
    FirmwareUpdatedEvent e,
    ITimelineWriter writer,
    CancellationToken ct) =>
    writer.PostEntryAsync(
    entityType: "Device",
    entityId: e.DeviceId.ToString(),
    entryType: TimelineEntryType.SystemLog,
    body: $"Firmware updated: {e.PreviousVersion}{e.NewVersion}",
    cancellationToken: ct);

Wolverine picks it up automatically.