Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public override async Task HandleItemAsync(WorkItemContext context)
}

if (wi.UpgradePlans || wi.RemoveOldUsageStats)
await _organizationRepository.SaveAsync(results.Documents);
await _organizationRepository.SaveAsync(results.Documents, o => o.Notifications(wi.UpgradePlans));

// Sleep so we are not hammering the backend.
await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public override async Task HandleItemAsync(WorkItemContext context)
}

if (workItem.UpdateDefaultBotList || workItem.IncrementConfigurationVersion || workItem.RemoveOldUsageStats)
await _projectRepository.SaveAsync(results.Documents);
await _projectRepository.SaveAsync(results.Documents, o => o.Notifications(workItem.UpdateDefaultBotList || workItem.IncrementConfigurationVersion));

// Sleep so we are not hammering the backend.
await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider);
Expand Down
11 changes: 9 additions & 2 deletions src/Exceptionless.Core/Migrations/UpdateEventUsage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ private async Task UpdateOrganizationsUsageAsync(MigrationContext context)
using var _ = _logger.BeginScope(new ExceptionlessState().Organization(organization.Id));
try
{
int effectiveMonthlyLimit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
var currentMonthUtc = _timeProvider.GetUtcNow().UtcDateTime.StartOfMonth();
int currentMonthTotalBeforeRepair = organization.Usage.FirstOrDefault(u => u.Date == currentMonthUtc)?.Total ?? 0;
var result = await _eventRepository.CountAsync(q => q.Organization(organization.Id).AggregationsExpression("date:date~1M"));
var dateAggs = result.Aggregations.DateHistogram("date_date");
if (dateAggs?.Buckets is null)
Expand All @@ -86,7 +89,11 @@ private async Task UpdateOrganizationsUsageAsync(MigrationContext context)
}
}

await _organizationRepository.SaveAsync(organization);
int currentMonthTotalAfterRepair = organization.Usage.FirstOrDefault(u => u.Date == currentMonthUtc)?.Total ?? 0;
bool monthlyOverageStarted = effectiveMonthlyLimit >= 0
&& currentMonthTotalBeforeRepair < effectiveMonthlyLimit
&& currentMonthTotalAfterRepair >= effectiveMonthlyLimit;
await _organizationRepository.SaveAsync(organization, o => o.Notifications(monthlyOverageStarted));
await UpdateProjectsUsageAsync(context, organization);
processed++;
await context.Lock.RenewAsync();
Expand Down Expand Up @@ -142,7 +149,7 @@ private async Task UpdateProjectsUsageAsync(MigrationContext context, Organizati
usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
}

await _projectRepository.SaveAsync(project);
await _projectRepository.SaveAsync(project, o => o.Notifications(false));
}
catch (Exception ex)
{
Expand Down
55 changes: 49 additions & 6 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ private async Task SavePendingOrganizationUsageAsync(DateTime utcNow)
{
if (organizationIdsValue.HasValue)
{
foreach (string? organizationId in organizationIdsValue.Value)
await PublishPendingHourlyOverageAsync(bucketUtc, organizationId);

// Should we wait to remove this in case there is a failure? We should just remove the organization id once processed.
await _cache.RemoveAsync(GetOrganizationSetKey(bucketUtc));

Expand All @@ -85,11 +88,17 @@ private async Task SavePendingOrganizationUsageAsync(DateTime utcNow)
var bucketDiscarded = await _cache.GetAsync<int>(GetBucketDiscardedCacheKey(bucketUtc, organizationId));
var bucketTooBig = await _cache.GetAsync<int>(GetBucketTooBigCacheKey(bucketUtc, organizationId));
var bucketDeleted = await _cache.GetAsync<int>(GetBucketDeletedCacheKey(bucketUtc, organizationId));
var hourlyThrottleTransition = await _cache.GetAsync<bool>(GetHourlyThrottleTransitionKey(bucketUtc, organizationId));

bool hasIngestion = (bucketTotal?.Value ?? 0) > 0 || (bucketBlocked?.Value ?? 0) > 0 || (bucketDiscarded?.Value ?? 0) > 0 || (bucketTooBig?.Value ?? 0) > 0;
if (hasIngestion)
organization.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime;

int bucketLimit = GetBucketEventLimit(organization.GetMaxEventsPerMonthWithBonus(_timeProvider), bucketUtc);
bool hourlyThrottleCleared = hourlyThrottleTransition is { HasValue: true } transition
? transition.Value
: bucketLimit >= 0 && bucketTotal is { HasValue: true } total && total.Value >= bucketLimit;

var usage = organization.GetUsage(bucketUtc, _timeProvider);
usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider);
usage.Total += bucketTotal?.Value ?? 0;
Expand All @@ -113,11 +122,13 @@ await _cache.RemoveAllAsync(new[] {
GetBucketDiscardedCacheKey(bucketUtc, organizationId),
GetBucketTooBigCacheKey(bucketUtc, organizationId),
GetBucketDeletedCacheKey(bucketUtc, organizationId),
GetThrottledKey(bucketUtc, organizationId)
GetThrottledKey(bucketUtc, organizationId),
GetHourlyThrottleTransitionKey(bucketUtc, organizationId)
});

await _cache.SetAsync(GetTotalCacheKey(utcNow, organizationId), usage.Total, TimeSpan.FromHours(8));
await _organizationRepository.SaveAsync(organization);
// Routine usage updates stay silent, but clients need one refresh when hourly throttling clears.
await _organizationRepository.SaveAsync(organization, o => o.Notifications(hourlyThrottleCleared));
}
}

Expand Down Expand Up @@ -204,7 +215,8 @@ await _cache.RemoveAllAsync(new[] {

await _cache.SetAsync(GetTotalCacheKey(utcNow, project.OrganizationId, projectId), usage.Total, TimeSpan.FromHours(8));

await _projectRepository.SaveAsync(project);
// Project configuration changes have their own save path and should remain the source of ProjectChanged messages.
await _projectRepository.SaveAsync(project, o => o.Notifications(false));
Comment thread
ejsmith marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -257,8 +269,10 @@ public async Task HandleOrganizationChangeAsync(Organization modified, Organizat

if (bucketTotal.Value >= bucketLimit)
{
await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = modified.Id, IsHourly = true });
await _cache.SetAsync(GetThrottledKey(utcNow, modified.Id), true, TimeSpan.FromMinutes(5));
await _cache.SetAsync(GetHourlyThrottleTransitionKey(utcNow, modified.Id), true, TimeSpan.FromHours(8));
await _cache.SetAsync(GetHourlyOveragePendingKey(utcNow, modified.Id), true, TimeSpan.FromHours(8));
await PublishPendingHourlyOverageAsync(utcNow, modified.Id);
}
}

Expand Down Expand Up @@ -449,11 +463,24 @@ public async Task IncrementTotalAsync(string organizationId, string projectId, i
if (bucketTotal >= bucketLimit && bucketTotal - bucketLimit < eventCount)
{
// org will be throttled during the current bucket of time
await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId, IsHourly = true });
await _cache.SetAsync(GetThrottledKey(utcNow, organizationId), true, TimeSpan.FromMinutes(5));
await _cache.SetAsync(GetHourlyThrottleTransitionKey(utcNow, organizationId), true, TimeSpan.FromHours(8));
await _cache.SetAsync(GetHourlyOveragePendingKey(utcNow, organizationId), true, TimeSpan.FromHours(8));
await PublishPendingHourlyOverageAsync(utcNow, organizationId);
}
}

private async Task PublishPendingHourlyOverageAsync(DateTime utcTime, string organizationId)
{
string pendingKey = GetHourlyOveragePendingKey(utcTime, organizationId);
var pending = await _cache.GetAsync<bool>(pendingKey);
if (!pending.HasValue || !pending.Value)
return;

await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId, IsHourly = true });
await _cache.RemoveAsync(pendingKey);
}

public async Task IncrementBlockedAsync(string organizationId, string? projectId, int eventCount = 1)
{
if (eventCount <= 0)
Expand Down Expand Up @@ -527,11 +554,15 @@ public async Task IncrementDeletedAsync(string organizationId, string? projectId
}

private int GetBucketEventLimit(int maxEventsPerMonth)
{
return GetBucketEventLimit(maxEventsPerMonth, _timeProvider.GetUtcNow().UtcDateTime);
}

private int GetBucketEventLimit(int maxEventsPerMonth, DateTime utcNow)
{
if (maxEventsPerMonth < 5000)
return maxEventsPerMonth;

var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
var timeLeftInMonth = utcNow.EndOfMonth() - utcNow;
if (timeLeftInMonth < TimeSpan.FromDays(1))
return maxEventsPerMonth;
Expand Down Expand Up @@ -614,6 +645,18 @@ private string GetThrottledKey(DateTime utcTime, string organizationId)
return $"usage:{bucket}:{organizationId}:throttled";
}

private string GetHourlyThrottleTransitionKey(DateTime utcTime, string organizationId)
{
int bucket = GetCurrentBucket(utcTime);
return $"usage:{bucket}:{organizationId}:throttled-transition";
}

private string GetHourlyOveragePendingKey(DateTime utcTime, string organizationId)
{
int bucket = GetCurrentBucket(utcTime);
return $"usage:{bucket}:{organizationId}:hourly-overage-pending";
}

private string GetProjectSetKey(DateTime utcTime)
{
int bucket = GetCurrentBucket(utcTime);
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -923,12 +923,13 @@ private async Task AfterResultMapAsync<TDestination>(ICollection<TDestination> m
viewOrganization.TrimUsage(timeProvider);

var currentUsage = viewOrganization.GetCurrentUsage(timeProvider);
currentUsage.Limit = realTimeUsage.CurrentUsage.Limit;
currentUsage.Limit = viewOrganization.GetMaxEventsPerMonthWithBonus(timeProvider);
currentUsage.Total = realTimeUsage.CurrentUsage.Total;
currentUsage.Blocked = realTimeUsage.CurrentUsage.Blocked;
currentUsage.Discarded = realTimeUsage.CurrentUsage.Discarded;
currentUsage.TooBig = realTimeUsage.CurrentUsage.TooBig;
currentUsage.Deleted = realTimeUsage.CurrentUsage.Deleted;
viewOrganization.IsOverMonthlyLimit = currentUsage.Limit >= 0 && currentUsage.Total >= currentUsage.Limit;
Comment thread
ejsmith marked this conversation as resolved.

var currentHourUsage = viewOrganization.GetCurrentHourlyUsage(timeProvider);
currentHourUsage.Total = realTimeUsage.CurrentHourUsage.Total;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,30 @@

angular
.module("exceptionless.organization", ["restangular"])
.factory("organizationService", function ($cacheFactory, $rootScope, objectIDService, Restangular) {
.factory("organizationService", function ($cacheFactory, $interval, $rootScope, objectIDService, Restangular) {
var _cache = $cacheFactory("http:organization");
var _usageMonth = getUsageMonth();
$rootScope.$on("cache:clear", _cache.removeAll);
$rootScope.$on("cache:clear-organization", _cache.removeAll);
$rootScope.$on("auth:logout", _cache.removeAll);
$rootScope.$on("OrganizationChanged", _cache.removeAll);
Comment thread
ejsmith marked this conversation as resolved.
$rootScope.$on("ProjectChanged", _cache.removeAll);
$rootScope.$on("PlanOverage", _cache.removeAll);

$interval(function () {
var usageMonth = getUsageMonth();
if (usageMonth === _usageMonth) {
return;
}

_usageMonth = usageMonth;
$rootScope.$broadcast("OrganizationChanged", {});
}, 60000);

function getUsageMonth() {
var now = new Date();
return now.getUTCFullYear() * 12 + now.getUTCMonth();
}

$rootScope.$on("StackChanged", function ($event, data) {
if (data.added) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me
}
}

export async function invalidateOrganizationUsageQueries(queryClient: QueryClient, organizationId?: string) {
const invalidations = [queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) })];
if (organizationId) {
invalidations.push(queryClient.invalidateQueries({ exact: true, queryKey: queryKeys.id(organizationId, undefined) }));
invalidations.push(queryClient.invalidateQueries({ exact: true, queryKey: queryKeys.id(organizationId, 'stats') }));
}

await Promise.all(invalidations);
}

export async function invalidatePlanOverageQueries(queryClient: QueryClient, message: WebSocketMessageValue<'PlanOverage'>) {
await invalidateOrganizationUsageQueries(queryClient, message.organization_id);
}

export const queryKeys = {
adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const,
changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const,
Expand Down Expand Up @@ -108,6 +122,7 @@ export interface GetOrganizationRequest {
params?: {
mode: 'stats' | undefined;
};
refetchInterval?: false | number;
route: {
id: string | undefined;
};
Expand Down Expand Up @@ -360,7 +375,8 @@ export function getOrganizationQuery(request: GetOrganizationRequest) {

return response.data!;
},
queryKey: queryKeys.id(request.route.id, request.params?.mode)
queryKey: queryKeys.id(request.route.id, request.params?.mode),
refetchInterval: request.refetchInterval
}));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { QueryClient } from '@tanstack/svelte-query';
import { describe, expect, it, vi } from 'vitest';

import { invalidateOrganizationUsageQueries, invalidatePlanOverageQueries, queryKeys } from './api.svelte';

describe('invalidatePlanOverageQueries', () => {
it('invalidates only the affected organization state', async () => {
// Arrange
const queryClient = new QueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {});

// Act
await invalidatePlanOverageQueries(queryClient, {
is_hourly: false,
organization_id: 'organization-id'
});

// Assert
expect(invalidateSpy).toHaveBeenCalledTimes(3);
expect(invalidateSpy).toHaveBeenCalledWith({ exact: true, queryKey: queryKeys.id('organization-id', undefined) });
expect(invalidateSpy).toHaveBeenCalledWith({ exact: true, queryKey: queryKeys.id('organization-id', 'stats') });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.list(undefined) });
});
});

describe('invalidateOrganizationUsageQueries', () => {
it('invalidates organization lists when there is no active organization', async () => {
// Arrange
const queryClient = new QueryClient();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(async () => {});

// Act
await invalidateOrganizationUsageQueries(queryClient);

// Assert
expect(invalidateSpy).toHaveBeenCalledOnce();
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.list(undefined) });
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
<script module lang="ts">
import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models';

export function recordConfiguredProjectId(
message: WebSocketMessageValue<'PersistentEventChanged'>,
organizationId: string | undefined,
configuredProjectIds: Set<string>
): boolean {
if (
message.change_type === ChangeType.Removed ||
message.organization_id !== organizationId ||
!message.project_id ||
configuredProjectIds.has(message.project_id)
) {
return false;
}

configuredProjectIds.add(message.project_id);
return true;
}
</script>

<script lang="ts">
import type { NotificationProps } from '$comp/notification';

Expand All @@ -6,10 +28,8 @@
import { SuspensionCode } from '$features/organizations/models';
import { getOrganizationProjectsQuery } from '$features/projects/api.svelte';
import { getMeQuery } from '$features/users/api.svelte';
import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models';
import { useEventListener } from 'runed';
import { SvelteSet } from 'svelte/reactivity';
import { debounce } from 'throttle-debounce';

import FreePlanNotification from './notifications/free-plan-notification.svelte';
import HourlyOverageNotification from './notifications/hourly-overage-notification.svelte';
Expand Down Expand Up @@ -95,19 +115,9 @@
);
const requiresPremiumUpgrade = $derived(requiresPremium && !organization?.has_premium_features && !needsProjectConfiguration);

const refetchConfigurationState = debounce(1500, async () => {
await Promise.all([organizationQuery.refetch(), projectsQuery.refetch()]);
});

useEventListener(document, 'PersistentEventChanged', (event) => {
const message = (event as CustomEvent<WebSocketMessageValue<'PersistentEventChanged'>>).detail;

if (message.change_type === ChangeType.Removed || message.organization_id !== currentOrganizationId.current || !message.project_id) {
return;
}

configuredProjectIds.add(message.project_id);
void refetchConfigurationState();
recordConfiguredProjectId(message, currentOrganizationId.current, configuredProjectIds);
});
</script>

Expand Down
Loading
Loading