Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2a4259d
Fix historical usage limit backfill
niemyjski Jul 28, 2026
732e20d
Derive usage backfill from known limits
niemyjski Jul 29, 2026
76bcf53
Update plan change usage assertions
niemyjski Jul 29, 2026
ab5fb11
Consolidate usage bucket creation
niemyjski Jul 29, 2026
29b9ba2
Bound usage history by organization creation
niemyjski Jul 30, 2026
a368f83
Update new organization usage expectations
niemyjski Jul 30, 2026
da721bd
Update plan change usage count expectation
niemyjski Jul 30, 2026
2ac9125
Harden usage history reconstruction
niemyjski Jul 30, 2026
cb302a4
Keep usage history fix scoped
niemyjski Jul 30, 2026
0fe417d
Anchor bonuses to historical month
niemyjski Jul 30, 2026
1f0e759
Handle usage bonus month boundaries
niemyjski Jul 30, 2026
79c99bf
Disambiguate equal historical limits
niemyjski Jul 30, 2026
733df8d
Repair zero-valued usage anchors
niemyjski Jul 30, 2026
fc1eb7b
Anchor only confirmed plan history
niemyjski Jul 30, 2026
bc3c8b1
Compare plan identifiers consistently
niemyjski Jul 30, 2026
51888cd
Preserve usage fallback base limits
niemyjski Jul 30, 2026
412b87d
Preserve outgoing usage anchors with new bonuses
niemyjski Jul 30, 2026
b45b376
Stop expired bonus limits from carrying forward
niemyjski Jul 30, 2026
2946748
Preserve equal-valued outgoing usage anchors
niemyjski Jul 30, 2026
53696f5
Preserve billing change dates during maintenance
niemyjski Jul 30, 2026
3e746d0
Treat missing billing history as unknown
niemyjski Jul 30, 2026
7695a18
Bound project usage to creation dates
niemyjski Jul 30, 2026
9c40209
Simplify usage history backfill
niemyjski Jul 30, 2026
7fdbf4f
Fix usage history materialization
niemyjski Aug 9, 2026
c0080fc
Preserve usage anchors during plan updates
niemyjski Aug 12, 2026
531ab34
Preserve project limits when flushing historical usage
niemyjski Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/Exceptionless.Core/Billing/BillingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Billing;
using Exceptionless.Core.Repositories;
using Exceptionless.DateTimeExtensions;
using Foundatio.Lock;

namespace Exceptionless.Core.Billing;
Expand Down Expand Up @@ -106,16 +107,23 @@ public async Task<ChangePlanResult> CanDownGradeAsync(Organization organization,

public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true)
{
var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
CaptureOutgoingUsageLimit(organization, plan, utcNow);

organization.PlanId = plan.Id;
organization.PlanName = plan.Name;
organization.PlanDescription = plan.Description;
organization.BillingChangeDate = _timeProvider.GetUtcNow().UtcDateTime;
organization.BillingChangeDate = utcNow;

if (updateBillingPrice)
{
organization.BillingPrice = plan.Price;
}

if (user is not null)
{
organization.BillingChangedByUserId = user.Id;
}

organization.MaxUsers = plan.MaxUsers;
organization.MaxProjects = plan.MaxProjects;
Expand All @@ -126,6 +134,31 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User?
organization.GetCurrentUsage(_timeProvider).Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
}

private void CaptureOutgoingUsageLimit(Organization organization, BillingPlan plan, DateTime utcNow)
{
bool isUnchangedPlan = String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase)
&& organization.MaxEventsPerMonth == plan.MaxEventsPerMonth;
if (String.IsNullOrEmpty(organization.PlanId)
|| isUnchangedPlan
|| organization.MaxEventsPerMonth == 0)
{
return;
}

var previousMonthUtc = utcNow.StartOfMonth().AddMonths(-1);
var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth();
if (previousMonthUtc < organizationCreatedMonthUtc)
{
return;
}

var previousUsage = organization.Usage.GetOrAddMonthlyUsage(previousMonthUtc, organization.MaxEventsPerMonth);
Comment thread
niemyjski marked this conversation as resolved.
if (previousUsage.Limit == 0)
{
previousUsage.Limit = organization.MaxEventsPerMonth;
}
}

public void ApplyBonus(Organization organization, int bonusEvents, DateTime? expires = null)
{
organization.BonusEventsPerMonth = bonusEvents;
Expand Down
14 changes: 1 addition & 13 deletions src/Exceptionless.Core/Extensions/OrganizationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,6 @@ public static UsageInfo GetCurrentUsage(this Organization organization, TimeProv

public static UsageInfo GetUsage(this Organization organization, DateTime date, TimeProvider timeProvider)
{
var startOfMonth = date.ToUniversalTime().StartOfMonth();
var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month);
if (usage is not null)
return usage;

usage = new UsageInfo
{
Date = startOfMonth,
Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider)
};
organization.Usage.Add(usage);

return usage;
return organization.Usage.GetOrAddMonthlyUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider));
}
}
12 changes: 1 addition & 11 deletions src/Exceptionless.Core/Extensions/ProjectExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,6 @@ public static UsageInfo GetCurrentUsage(this Project project, TimeProvider timeP

public static UsageInfo GetUsage(this Project project, DateTime date)
{
var usage = project.Usage.FirstOrDefault(o => o.Date == date.ToUniversalTime().StartOfMonth());
if (usage is not null)
return usage;

usage = new UsageInfo
{
Date = date.ToUniversalTime().StartOfMonth(),
};
project.Usage.Add(usage);

return usage;
return project.Usage.GetOrAddMonthlyUsage(date, 0);
}
}
51 changes: 51 additions & 0 deletions src/Exceptionless.Core/Extensions/UsageExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,60 @@
using Exceptionless.Core.Models;
using Exceptionless.DateTimeExtensions;

namespace Exceptionless.Core.Extensions;

public static class UsageExtensions
{
public static UsageInfo GetOrAddMonthlyUsage(this ICollection<UsageInfo> usages, DateTime dateUtc, int limit)
{
var startOfMonth = dateUtc.ToUniversalTime().StartOfMonth();
var usage = usages.FirstOrDefault(u => u.Date.Year == startOfMonth.Year && u.Date.Month == startOfMonth.Month);
if (usage is not null)
{
return usage;
Comment thread
niemyjski marked this conversation as resolved.
}

usage = new UsageInfo
{
Date = startOfMonth,
Limit = limit
};
usages.Add(usage);

return usage;
}

public static ICollection<UsageInfo> MaterializeMonthlyUsage(this IEnumerable<UsageInfo> usages, DateTime startDateUtc, DateTime endDateUtc, int fallbackLimit)
{
var materialized = usages.Select(usage => usage with { }).ToList();
var startOfMonthUtc = startDateUtc.ToUniversalTime().StartOfMonth();
var endOfMonthUtc = endDateUtc.ToUniversalTime().StartOfMonth();
if (startOfMonthUtc > endOfMonthUtc)
return materialized;

var knownUsages = materialized
.Where(usage => usage.Limit != 0)
.OrderBy(usage => usage.Date)
.ToList();
int limit = knownUsages
.LastOrDefault(usage => usage.Date <= startOfMonthUtc)?.Limit
?? knownUsages.FirstOrDefault()?.Limit
?? fallbackLimit;

while (startOfMonthUtc <= endOfMonthUtc)
{
var usage = materialized.GetOrAddMonthlyUsage(startOfMonthUtc, limit);
if (usage.Limit == 0)
usage.Limit = limit;
else
limit = usage.Limit;

startOfMonthUtc = startOfMonthUtc.AddMonths(1);
}

return materialized.OrderBy(usage => usage.Date).ToList();
}

public static void SetUsage(this ICollection<UsageInfo> usages, DateTime dateUtc, int total, int blocked, int tooBig, int limit, TimeSpan? maxUsageAge, TimeProvider timeProvider)
{
var usageInfo = usages.FirstOrDefault(o => o.Date == dateUtc);
Expand Down
22 changes: 16 additions & 6 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ long GetAssistantValue(string metric)
organization.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime;

bool hasEventUsage = hasIngestion || (bucketDeleted?.Value ?? 0) > 0;
int bucketLimit = GetBucketEventLimit(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), bucketUtc);
int currentMonthlyLimit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
int bucketLimit = GetBucketEventLimit(currentMonthlyLimit, bucketUtc);
bool hourlyThrottleCleared = hourlyThrottleTransition is { HasValue: true } transition
? transition.Value
: bucketLimit >= 0 && bucketTotal is { HasValue: true } total && total.Value >= bucketLimit;
Expand All @@ -162,7 +163,8 @@ long GetAssistantValue(string metric)
if (hasEventUsage)
{
var usage = organization.GetUsage(bucketUtc, _timeProvider);
usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
if (usage.Limit == 0 || bucketUtc.StartOfMonth() == utcNow.StartOfMonth())
usage.Limit = currentMonthlyLimit;
usage.Total += bucketTotal?.Value ?? 0;
usage.Blocked += bucketBlocked?.Value ?? 0;
usage.Discarded += bucketDiscarded?.Value ?? 0;
Expand Down Expand Up @@ -328,11 +330,19 @@ private async Task SavePendingProjectUsageAsync(DateTime utcNow)
if (hasIngestion)
project.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime;

(string OrganizationId, Organization? Organization) context = (OrganizationId: project.OrganizationId, Organization: null);
int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(context);

var usage = project.GetUsage(bucketUtc);
usage.Limit = maxEventsPerMonth;
bool isCurrentMonth = bucketUtc.StartOfMonth() == utcNow.StartOfMonth();
if (usage.Limit == 0 || isCurrentMonth)
{
(string OrganizationId, Organization? Organization) context = (project.OrganizationId, null);
if (!isCurrentMonth)
context.Organization = await _organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache());

int? historicalLimit = context.Organization?.Usage
.FirstOrDefault(monthlyUsage => monthlyUsage.Date.Year == bucketUtc.Year
&& monthlyUsage.Date.Month == bucketUtc.Month && monthlyUsage.Limit != 0)?.Limit;
usage.Limit = historicalLimit ?? await GetMaxEventsPerMonthAsync(context);
}
usage.Total += bucketTotal?.Value ?? 0;
usage.Blocked += bucketBlocked?.Value ?? 0;
usage.Discarded += bucketDiscarded?.Value ?? 0;
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ private async Task AfterResultMapAsync<TDestination>(ICollection<TDestination> m
viewProject.HasPremiumFeatures = organization.HasPremiumFeatures;

var realTimeUsage = await usageService.GetUsageAsync(organization.Id, viewProject.Id);
viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider);
viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), organization.CreatedUtc, timeProvider);
viewProject.TrimUsage(timeProvider);

var currentUsage = viewProject.GetCurrentUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider);
Expand Down
31 changes: 11 additions & 20 deletions src/Exceptionless.Web/Models/Organization/ViewOrganization.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.DateTimeExtensions;
using Foundatio.Repositories.Models;
Expand Down Expand Up @@ -75,13 +76,15 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza

public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider)
{
var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractMonths(11).StartOfMonth();

while (startDate <= timeProvider.GetUtcNow().UtcDateTime.StartOfMonth())
{
organization.GetUsage(startDate, timeProvider);
startDate = startDate.AddMonths(1).StartOfMonth();
}
var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth();
var startDateUtc = endDateUtc.SubtractMonths(11);
var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth();
startDateUtc = organizationCreatedMonthUtc > startDateUtc ? organizationCreatedMonthUtc : startDateUtc;

organization.Usage = organization.Usage.MaterializeMonthlyUsage(
startDateUtc,
endDateUtc,
organization.GetMaxEventsPerMonthWithBonus(timeProvider));
}

public static UsageInfo GetCurrentUsage(this ViewOrganization organization, TimeProvider timeProvider)
Expand All @@ -91,19 +94,7 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time

public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider)
{
var startOfMonth = date.ToUniversalTime().StartOfMonth();
var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month);
if (usage is not null)
return usage;

usage = new UsageInfo
{
Date = startOfMonth,
Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider)
};
organization.Usage.Add(usage);

return usage;
return organization.Usage.GetOrAddMonthlyUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider));
}

public static int GetMaxEventsPerMonthWithBonus(this ViewOrganization organization, TimeProvider timeProvider)
Expand Down
29 changes: 10 additions & 19 deletions src/Exceptionless.Web/Models/Project/ViewProject.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Models;
using Exceptionless.DateTimeExtensions;
using Foundatio.Repositories.Models;
Expand Down Expand Up @@ -49,15 +50,16 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewProject project, Time
return project.GetHourlyUsage(timeProvider.GetUtcNow().UtcDateTime);
}

public static void EnsureUsage(this ViewProject project, int limit, TimeProvider timeProvider)
public static void EnsureUsage(this ViewProject project, int limit, DateTime organizationCreatedUtc, TimeProvider timeProvider)
{
var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractYears(1).StartOfMonth();
var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth();
var startDateUtc = endDateUtc.SubtractYears(1);
var organizationCreatedMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth();
var projectCreatedMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth();
var createdMonthUtc = projectCreatedMonthUtc > organizationCreatedMonthUtc ? projectCreatedMonthUtc : organizationCreatedMonthUtc;
startDateUtc = createdMonthUtc > startDateUtc ? createdMonthUtc : startDateUtc;

while (startDate < timeProvider.GetUtcNow().UtcDateTime.StartOfMonth())
{
project.GetUsage(startDate, limit);
startDate = startDate.AddMonths(1).StartOfMonth();
}
project.Usage = project.Usage.MaterializeMonthlyUsage(startDateUtc, endDateUtc.AddMonths(-1), limit);
}

public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, TimeProvider timeProvider)
Expand All @@ -67,18 +69,7 @@ public static UsageInfo GetCurrentUsage(this ViewProject project, int limit, Tim

public static UsageInfo GetUsage(this ViewProject project, DateTime date, int limit)
{
var usage = project.Usage.FirstOrDefault(o => o.Date == date.ToUniversalTime().StartOfMonth());
if (usage is not null)
return usage;

usage = new UsageInfo
{
Date = date.ToUniversalTime().StartOfMonth(),
Limit = limit
};
project.Usage.Add(usage);

return usage;
return project.Usage.GetOrAddMonthlyUsage(date, limit);
}

public static void TrimUsage(this ViewProject project, TimeProvider timeProvider)
Expand Down
10 changes: 5 additions & 5 deletions tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1473,7 +1473,7 @@ public async Task ShouldRespectEventUsageLimits()
Assert.NotNull(viewOrganization);
Assert.False(viewOrganization.IsThrottled);
Assert.False(viewOrganization.IsOverMonthlyLimit);
Assert.Equal(12, viewOrganization.Usage.Count);
Assert.Single(viewOrganization.Usage);
Assert.Single(viewOrganization.UsageHours);

// submit bach of events one over limit
Expand All @@ -1497,7 +1497,7 @@ await SendRequestAsync(r => r
Assert.NotNull(viewOrganization);
Assert.False(viewOrganization.IsThrottled);
Assert.False(viewOrganization.IsOverMonthlyLimit);
Assert.Equal(12, viewOrganization.Usage.Count);
Assert.Single(viewOrganization.Usage);
Assert.Single(viewOrganization.UsageHours);

// process events
Expand Down Expand Up @@ -1596,7 +1596,7 @@ await SendRequestAsync(r => r
organization = await _organizationRepository.GetByIdAsync(organizationId);
Assert.NotNull(organization);

organizationUsage = organization.Usage.Single();
organizationUsage = organization.GetCurrentUsage(TimeProvider);
Assert.Equal(total, organizationUsage.Total);
Assert.Equal(blocked, organizationUsage.Blocked);
Assert.Equal(0, organizationUsage.TooBig);
Expand Down Expand Up @@ -1725,7 +1725,7 @@ public async Task PlanChangeShouldAllowEventSubmission()
Assert.NotNull(viewOrganization);
Assert.False(viewOrganization.IsThrottled);
Assert.False(viewOrganization.IsOverMonthlyLimit);
Assert.Equal(12, viewOrganization.Usage.Count);
Assert.Single(viewOrganization.Usage);
Assert.Single(viewOrganization.UsageHours);

// submit bach of events one over limit
Expand Down Expand Up @@ -1857,7 +1857,7 @@ await SendRequestAsync(r => r
organization = await _organizationRepository.GetByIdAsync(organizationId);
Assert.NotNull(organization);

organizationUsage = organization.Usage.Single();
organizationUsage = organization.GetCurrentUsage(TimeProvider);
Assert.Equal(total, organizationUsage.Total);
Assert.Equal(blocked, organizationUsage.Blocked);
Assert.Equal(0, organizationUsage.TooBig);
Expand Down
Loading
Loading