Skip to content

Metering — Usage Tracking & Quota Enforcement

Granit.Metering records usage events as append-only time-series data, aggregates them into rollups via a watermark-based cursor (guaranteed no double-counting), and enforces quotas with threshold alerts. The module is agnostic — it works for API call metering, IoT telemetry, storage tracking, or any usage-based scenario.

  • DirectoryGranit.Metering/ Domain: MeterDefinition, MeterEvent, UsageAggregate, AggregationWatermark
    • Granit.Metering.EntityFrameworkCore EF Core store, insert-first dedup, watermark tracking
    • Granit.Metering.Endpoints Usage reporting, quota status, meter admin with RBAC
    • Granit.Metering.BackgroundJobs Hourly aggregation (watermark), quota threshold checks
    • Granit.Metering.Wolverine Event handlers (minimal — grows with integration)

Defines what to measure and how to aggregate:

var meter = MeterDefinition.Create(
Guid.NewGuid(), "API Calls", "requests",
AggregationType.Sum, "HTTP API request counter");
meter.Deactivate(); // Stops accepting new events
meter.Activate(); // Re-enables event recording
Aggregation typeDescription
SumSum of all event quantities in the period
MaxMaximum event quantity in the period
CountCount of events (ignores quantity)
LastLast recorded value (gauge-style)

MeterEvents are time-series data — no state transitions, no EF Core change tracking overhead. Designed for high-throughput bulk inserts:

var evt = MeterEvent.Create(
Guid.NewGuid(),
meterDefinitionId,
idempotencyKey: $"req-{requestId}",
quantity: 1m,
timestamp: clock.Now,
metadata: """{"endpoint":"/api/users","method":"GET"}""");

Deduplication is handled at the persistence layer via a unique index on (TenantId, IdempotencyKey). Duplicates are silently ignored (insert-first pattern).

Pre-computed rollup for fast queries:

// Automatically computed by MeteringAggregationJob
UsageAggregate
├── MeterDefinitionId
├── Period: Hourly | Daily | BillingPeriod
├── PeriodStart / PeriodEnd
├── AggregatedValue: 15,234
└── EventCount: 15,234
InterfaceSideMethods
IMeterDefinitionReaderQueryGetByIdAsync, GetByNameAsync, GetActiveAsync
IMeterDefinitionWriterCommandAddAsync, UpdateAsync
IMeterEventRecorderCommandRecordAsync, RecordBatchAsync (dedup)
IUsageReaderQueryGetForPeriodAsync, GetCurrentAsync, GetAllForPeriodAsync
IQuotaCheckerQueryCheckAsync → QuotaStatus
IQuotaLimitProviderQueryGetLimitAsync — returns plan limit (default: unlimited)
IAggregationRunnerCommandRunAsync — aggregates pending events for all active meters
IBillingPeriodProviderQueryGetCurrentPeriodAsync — billing period boundaries for quota checks

The aggregation job uses a cursor pattern to ensure idempotent rollup computation without mutating append-only events:

sequenceDiagram
    participant Job as AggregationJob
    participant WM as AggregationWatermark
    participant Events as MeterEvents
    participant Agg as UsageAggregate

    Job->>WM: Read LastProcessedEventId
    Job->>Events: SELECT WHERE Id > LastProcessedEventId ORDER BY Id
    Job->>Job: Compute rollup (Sum/Max/Count/Last)
    Job->>Agg: Upsert UsageAggregate
    Job->>WM: Advance to max(Id)
    Note over Agg,WM: Same transaction (atomicity)

IQuotaChecker returns a QuotaStatus record:

var status = await quotaChecker.CheckAsync(tenantId, meterId, ct);
// QuotaStatus { MeterName, CurrentUsage, Limit, PercentUsed, IsExceeded }
// Factory methods for edge cases
QuotaStatus.Unlimited("API Calls", 500m); // No limit
QuotaStatus.WithLimit("API Calls", 800m, 1000m); // 80%, not exceeded

Quota checks sum hourly aggregates within the current billing period. The period boundaries are resolved via IBillingPeriodProvider:

public interface IBillingPeriodProvider
{
Task<BillingPeriodBoundaries?> GetCurrentPeriodAsync(
Guid tenantId, CancellationToken cancellationToken = default);
}
ProviderPackageResolution
CalendarMonthBillingPeriodProviderGranit.Metering1st to last day of the current month (default)
SubscriptionBillingPeriodProviderGranit.SubscriptionsSubscription.CurrentPeriodStart / CurrentPeriodEnd

When Granit.Subscriptions is loaded, it overrides the default with the actual subscription billing cycle. Without it (standalone metering), the calendar month is used as fallback.

The QuotaThresholdCheckJob runs every 15 minutes and publishes:

EventTrigger
QuotaThresholdReachedEtoUsage >= threshold% of limit (default 80%)
QuotaExceededEtoUsage >= 100% of limit

The threshold percentage is configurable via GranitMeteringOptions:

{
"Granit": {
"Metering": {
"ThresholdPercentage": 80
}
}
}

When a billing-period aggregation completes, UsageSummaryReadyEto is published. Subscriptions.Wolverine consumes it and creates an invoice with usage line items:

Metering ──UsageSummaryReadyEto──→ Subscriptions (orchestrator)
├── fixed: "Pro Plan — Monthly" × 1 × 29.99
├── usage: "API Calls: 15,000 requests" × 15000 × 0.001
└──CreateInvoiceCommand──→ Invoicing
JobCronDescription
MeteringAggregationJob0 */1 * * *Hourly rollup via watermark cursor
QuotaThresholdCheckJob*/15 * * * *Quota alerts at 80% and 100%

Meter: Granit.Metering

MetricTypeTags
granit.metering.event.recordedCountertenant_id, meter_name
granit.metering.event.deduplicatedCountertenant_id, meter_name
granit.metering.aggregation.completedCountertenant_id, meter_name
granit.metering.quota.threshold_reachedCountertenant_id, meter_name
granit.metering.quota.exceededCountertenant_id, meter_name

ActivitySource Granit.Metering with operations: RecordEvent, RecordBatch, Aggregate, CheckQuota.

Table prefix: metering_ (configurable via GranitMeteringDbProperties.DbTablePrefix).

ColumnTypeNotes
IdGUIDPrimary key
Namevarchar(200)Meter display name
Unitvarchar(50)Unit of measure
AggregationTypeintSum/Max/Count/Last
ActivatedboolAccepts new events
TenantIdGUID?Multi-tenant isolation
ColumnTypeNotes
IdGUIDPrimary key
MeterDefinitionIdGUIDFK to meter definition
IdempotencyKeyvarchar(256)Client-provided dedup key
Quantitydecimal(18,6)Measured quantity
TimestampdatetimeoffsetWhen usage occurred
Metadatavarchar(4000)Optional JSON context
TenantIdGUID?Multi-tenant isolation

Indexes:

  • ix_metering_meter_events_dedup — unique on (TenantId, IdempotencyKey)
  • ix_metering_meter_events_aggregation — on (TenantId, MeterDefinitionId, Id) for watermark queries
ColumnTypeNotes
IdGUIDPrimary key
MeterDefinitionIdGUIDWhich meter
LastProcessedEventIdGUIDCursor position
LastProcessedAtdatetimeoffset?Last aggregation time
TenantIdGUID?Multi-tenant isolation
MethodRoutePermission
GET/api/granit/metering/metersMetering.Meters.Read
POST/api/granit/metering/metersMetering.Meters.Manage
PUT/api/granit/metering/meters/{id}Metering.Meters.Manage
GET/api/granit/metering/usageMetering.Usage.Read
GET/api/granit/metering/quota/{meterId}Metering.Usage.Read
POST/api/granit/metering/eventsMetering.Usage.Record

All usage endpoints require tenant context. POST /events validates that each MeterDefinitionId in the batch belongs to the current tenant and is active.