Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
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
33 changes: 27 additions & 6 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,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 +119,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 +212,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));
}
}

Expand Down Expand Up @@ -257,8 +266,9 @@ 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 _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = modified.Id, IsHourly = true });
}
}

Expand Down Expand Up @@ -449,8 +459,9 @@ 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 _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId, IsHourly = true });
}
}

Expand Down Expand Up @@ -527,11 +538,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 +629,12 @@ 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 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute overage against the live plan limit

When an organization's temporary event bonus expires without another usage bucket being saved, realTimeUsage.CurrentUsage.Limit still contains the bonus-inflated limit written by BillingManager.ApplyBonus, while GetMaxEventsPerMonthWithBonus already returns the lower effective limit. This assignment therefore overrides the mapper's previously correct IsOverMonthlyLimit value and can leave the API response and overage banner false even when the persisted-plus-pending total now exceeds the live limit; compare the total against viewOrganization.GetMaxEventsPerMonthWithBonus(timeProvider) instead.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2aa10d3: real-time usage totals are now compared against the organization view model live effective monthly limit, so an expired bonus cannot keep the overage state false. The focused regression failed before the fix with Expected True, Actual False; OrganizationEndpointTests now pass 103 of 103 and the build is clean.


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);
$rootScope.$on("ProjectChanged", _cache.removeAll);
$rootScope.$on("PlanOverage", _cache.removeAll);

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

_usageMonth = usageMonth;
$rootScope.$broadcast("OrganizationChanged");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass a payload to the rollover notification

At UTC month rollover, this broadcasts OrganizationChanged without event data, but components/filter/filter-service.js:262-264 unconditionally dereferences organizationChanged.id. Every active legacy client therefore raises a TypeError during the synthetic monthly refresh; broadcast a compatible payload such as an empty object or update the listener to tolerate missing data.

Useful? React with 👍 / 👎.

}, 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