Skip to content

Analytics — Inline KPIs, Dashboards & BI Connectivity

Granit.Analytics brings first-class Business Intelligence to the framework — from a single counter above an admin grid, to fully composable Odoo-style dashboards, to native Power BI / Excel / Tableau connectivity — without compromising multi-tenancy isolation, GDPR, or ISO 27001 audit guarantees.

Every Granit-based application that needs analytics either rolls its own (hand-coded counters, ad-hoc SQL, no caching, no period comparison, no localization) or grants read-only SQL access to BI tools — a multi-tenant liability waiting to happen the day a custom view forgets a WHERE tenant_id = … clause. Granit.Analytics closes both gaps: KPIs are declared once and shared across inline displays, dashboards and external BI tools through a single, security-reviewed pipeline.

The framework’s BI story is structured in three additive layers, each useful on its own:

  • DirectoryLayer 1 — Inline metrics KPIs above a list, single-value tiles
    • Granit.Analytics Declarative MetricDefinition base classes
    • Granit.Analytics.EntityFrameworkCore EF Core executor with empty-set semantics
    • Granit.Analytics.Endpoints POST /metrics/:name surface with FusionCache
  • DirectoryLayer 2 — Composable dashboards Drag-drop layouts, multiple widget kinds
    • Granit.Analytics DashboardDefinition + widget contracts (planned)
    • Granit.Analytics.EntityFrameworkCore Persisted Dashboard aggregate (planned)
  • DirectoryLayer 3 — OData v4 connector Power BI / Excel / Tableau native consumption
    • Granit.Http.ODataExposure Bridge QueryDefinition → OData EntitySet (planned)
  • DirectoryGranit.Analytics Abstractions — no EF Core dependency
    • Metrics/MetricDefinition.cs Aggregation declaration + period selector
    • Metrics/MetricValueKind.cs Count / Number / Currency / Percentage / Duration / Date
    • Metrics/RefreshHint.cs Static / Dynamic / Realtime
    • Widgets/IWidgetSource.cs Pull-and-future-push widget contract
    • Widgets/WidgetPayload.cs Snapshot envelope with sequence + emitted-at metadata
  • DirectoryGranit.Analytics.EntityFrameworkCore Concrete EF Core executor
    • IMetricExecutor.cs Public façade — typed per (TEntity, TValue)
    • Internal/MetricExecutor.cs Typed dispatch per TValue (int / long / decimal / double)
  • DirectoryGranit.Analytics.Endpoints Minimal API surface
    • Endpoints/MetricEndpoints.cs POST /metrics/:name
    • Internal/MetricEndpointService.cs FusionCache + period comparison + delta
    • Internal/PeriodResolver.cs today / last_30d / mtd / qtd / ytd / previous_period
    • Permissions/AnalyticsPermissions.cs Analytics.Metrics.Read

A metric is a one-class declaration in your module’s Metrics/ folder, registered once in ConfigureServices. The framework takes care of the rest — caching, multi-tenancy, period filtering, comparison-window math, localization, and the HTTP surface.

src/Granit.Invoicing/Metrics/UnpaidInvoiceCountMetricDefinition.cs
public sealed class UnpaidInvoiceCountMetricDefinition : MetricDefinition<Invoice, int>
{
public override string Name => "Granit.Invoicing.UnpaidInvoiceCountMetric";
public override MetricValueKind ValueKind => MetricValueKind.Count;
public override AggregateFunction Aggregation => AggregateFunction.Count;
public override Expression<Func<Invoice, int?>>? Selector => null;
public override Expression<Func<Invoice, bool>>? BaseFilter
=> i => i.Status == InvoiceStatus.Open;
public override Expression<Func<Invoice, DateTimeOffset>>? PeriodSelector
=> i => i.IssuedAt!.Value;
public override bool IsHigherBetter => false; // fewer unpaid invoices = better
}

Naming, casing, and structural rules are codified in the Conventions page — they’re worth reading before adding a metric, because the same names land in:

  • Localization keys (Metric:{Name} across 18 cultures)
  • HTTP route parameters (POST /api/{version}/analytics/metrics/{Name} — exact path depends on the host’s API versioning prefix)
  • Dashboard widget references (Layer 2)
  • OData EntitySet routing (Layer 3)
  • Power BI / Excel client measure aliases

The contract every analytics consumer can rely on:

AggregationEmpty setSingle rowMulti row
Count01rows count
Sum0 (mathematical sum-of-empty)the row’s valuesum of values
Avg / Min / Maxnull (“no data”)the row’s valuecomputed

Without this contract, a tenant’s onboarding day — first dashboard view, zero rows — would either mislead with “Average invoice amount: 0 €” (because EF Core could silently coalesce) or throw InvalidOperationException. Locked by integration tests against both SQLite and PostgreSQL.

Layer 1 ships pull-based (FusionCache TTL 60–120 s). Real-time push (SSE by default, WebSocket as an opt-in sibling) is owned by the framework — Granit.Dashboards.Push under ADR-043. Hosts that need live channels load that package; hosts that don’t keep pull-only and degrade Realtime widgets to Dynamic cadence with no runtime breakage. IoT-specific producers (MQTT bridge, OPC-UA adapter) and IoT-specific widget definitions (sensor gauge, camera feed) live in granit-iot and consume the same framework IWidgetPushPublisher contract — the transport itself is not IoT-specific.

The wire-format envelope { Snapshot, Sequence, EmittedAt, RefreshHint } and the IWidgetSource<T> contract are locked v1 so the same source code serves both pull and push consumers.

  • Conventions — Naming rules, mandatory metric structure, localization keys, project layout. Read this first.
  • Inline metrics — Layer 1 walk-through: declare, register, render a MetricDefinition end-to-end.
  • Dashboards — Overview — Layer 2: composable widget grids that consume the same metrics via KpiWidgetDefinition.
  • Dashboards — Endpoints — REST surface that exposes the persisted Dashboard aggregate (catalogue / import / list / read / state transitions / widget CRUD).
  • OData feed (Power BI) — Layer 3: expose registered QueryDefinitions as OData v4 EntitySets so external BI tools (Power BI / Excel / Tableau) consume live data with tenant + soft-delete + permission filters applied transparently.