Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions src/Exceptionless.Core/Migrations/UpdateEventUsage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private async Task UpdateOrganizationsUsageAsync(MigrationContext context)
}
}

await _organizationRepository.SaveAsync(organization);
await _organizationRepository.SaveAsync(organization, o => o.Notifications(false));

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 Preserve overage transitions during usage migration

When this repeatable migration repairs a current-month usage total from below the live limit to at or above it, Notifications(false) removes the OrganizationChanged signal that previously refreshed connected clients, while this path publishes no replacement PlanOverage. Users with warm Svelte or Angular organization caches therefore continue seeing the organization as under limit until an unrelated refresh; suppress routine migration notifications, but emit a targeted signal when the repaired current-month total crosses the effective limit.

AGENTS.md reference: AGENTS.md:L74-L74

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 ee08a86: the repeatable usage repair migration now detects only a current-month transition from below the live effective monthly limit to at or above it, saves the repaired usage silently, then publishes one monthly PlanOverage. Repeat runs remain silent because the persisted total is already repaired. The regression reproduced the issue before the fix (Expected 1, Actual 0), runs the migration twice to prove one-shot behavior, and both UpdateEventUsageMigrationTests plus all 27 UsageServiceTests pass; the build is clean.

await UpdateProjectsUsageAsync(context, organization);
processed++;
await context.Lock.RenewAsync();
Expand Down Expand Up @@ -142,7 +142,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
22 changes: 17 additions & 5 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ 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)
Expand Down Expand Up @@ -113,11 +114,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 an hourly throttle clears.
await _organizationRepository.SaveAsync(organization, o => o.Notifications(hourlyThrottleTransition?.Value ?? false));

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 Clear legacy org cache on plan overage

When an organization crosses the monthly limit from pending usage, this save no longer emits OrganizationChanged, leaving PlanOverage as the only websocket signal. The legacy Angular banner listens for PlanOverage (src/Exceptionless.Web/ClientApp.angular/components/organization-notifications/organization-notifications-directive.tpl.html:4), but its vm.get() path calls organizationService.getAll() with the default cached Restangular client (src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js:94-99), and that cache is only cleared on OrganizationChanged/ProjectChanged (src/Exceptionless.Web/ClientApp.angular/components/organization/organization-service.js:11-12). The fresh evidence is this unchanged Angular cache path, so Angular users with a warm organization cache keep seeing stale is_over_monthly_limit until a later cache clear or reload; please either clear the Angular organization cache on PlanOverage or keep an organization invalidation for monthly overage transitions.

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 9bd0f02: the legacy organization cache is now cleared on PlanOverage, so its notification refresh reads current overage state.

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 Refresh organizations when monthly overage clears

When an organization is already over its monthly limit and the calendar rolls into a new month, the first usage save for the new month is a routine usage-only save with hourlyThrottleTransition false. Because this now calls Notifications(false), no OrganizationChanged is published, and there is no PlanOverage message for a clearing transition, so clients with cached is_over_monthly_limit: true can keep showing the monthly overage banner until a reload/focus refetch. Please keep a targeted organization notification or equivalent invalidation when monthly overage transitions from true to false.

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 bf75993: the first usage save of a new month detects an over-limit previous-month record and emits one OrganizationChanged. Later buckets remain silent. The regression test failed before the fix with Expected 1, Actual 0; the full UsageServiceTests suite now passes 21 of 21.

}
}

Expand Down Expand Up @@ -204,7 +207,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 +261,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 +454,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 @@ -614,6 +620,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
1 change: 1 addition & 0 deletions src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ private async Task AfterResultMapAsync<TDestination>(ICollection<TDestination> m
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 @@ -23,6 +23,12 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me
}
}

export async function invalidatePlanOverageQueries(queryClient: QueryClient, message: WebSocketMessageValue<'PlanOverage'>) {
await queryClient.invalidateQueries({ queryKey: queryKeys.id(message.organization_id, undefined) });
await queryClient.invalidateQueries({ queryKey: queryKeys.id(message.organization_id, 'stats') });
await queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) });
}

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { QueryClient } from '@tanstack/svelte-query';
import { describe, expect, it, vi } from 'vitest';

import { 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({ queryKey: queryKeys.id('organization-id', undefined) });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.id('organization-id', 'stats') });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.list(undefined) });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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 +94,21 @@
);
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) {
const projectNeedsConfiguration = projects.some((project) => project.id === message.project_id && project.is_configured === false);

if (
message.change_type === ChangeType.Removed ||
message.organization_id !== currentOrganizationId.current ||
!message.project_id ||
!projectNeedsConfiguration ||

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 Record configuration events before project data loads

When a PersistentEventChanged message arrives while the projects query is still loading, projects is empty, so this guard discards the message instead of recording its project_id. If the initial query then returns the project with is_configured: false before the asynchronous SetProjectIsConfiguredWorkItem finishes, the setup notification remains visible until that job publishes ProjectChanged (or indefinitely if the job is delayed or fails), whereas the previous handler recorded the event regardless of query timing. Preserve valid IDs even when the project has not loaded yet.

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 910499a: valid PersistentEventChanged project IDs are now recorded immediately for the current organization, without requiring the projects query to have loaded first. Once project data arrives, the local configured-ID set suppresses the stale setup notification without refetching organization or project data. Added a focused early-event regression; Svelte validation is clean and all 404 frontend unit tests pass.

configuredProjectIds.has(message.project_id)
) {
return;
}

configuredProjectIds.add(message.project_id);
void refetchConfigurationState();
});
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { ChangeType } from '$features/websockets/models';
import { render } from '@testing-library/svelte';
import { tick } from 'svelte';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import OrganizationNotifications from './organization-notifications.svelte';

const organizationRefetch = vi.hoisted(() => vi.fn());
const projectsRefetch = vi.hoisted(() => vi.fn());
const projects = vi.hoisted(() => [{ id: 'project-id', is_configured: false, organization_id: 'organization-id' }]);

vi.mock('$features/organizations/api.svelte', () => ({
getOrganizationQuery: () => ({ data: undefined, refetch: organizationRefetch }),
getOrganizationsQuery: () => ({ data: { data: [] } })
}));

vi.mock('$features/organizations/context.svelte', () => ({
organization: { current: 'organization-id' }
}));

vi.mock('$features/projects/api.svelte', () => ({
getOrganizationProjectsQuery: () => ({ data: { data: projects }, isSuccess: true, refetch: projectsRefetch })
}));

vi.mock('$features/users/api.svelte', () => ({
getMeQuery: () => ({ data: { organization_ids: ['organization-id'], roles: [] } })
}));

describe('OrganizationNotifications', () => {
beforeEach(() => {
organizationRefetch.mockReset();
projectsRefetch.mockReset();
projects[0]!.is_configured = false;
});

it('does not refetch organization or project state for persistent event changes', async () => {
render(OrganizationNotifications, {
isChatEnabled: false,
openChat: vi.fn()
});

const message = {
change_type: ChangeType.Added,
organization_id: 'organization-id',
project_id: 'project-id'
};

document.dispatchEvent(new CustomEvent('PersistentEventChanged', { detail: message }));
document.dispatchEvent(new CustomEvent('PersistentEventChanged', { detail: message }));
await tick();

expect(organizationRefetch).not.toHaveBeenCalled();
expect(projectsRefetch).not.toHaveBeenCalled();
});

it('does not refresh configuration state for an already configured project', async () => {
projects[0]!.is_configured = true;
render(OrganizationNotifications, {
isChatEnabled: false,
openChat: vi.fn()
});

document.dispatchEvent(
new CustomEvent('PersistentEventChanged', {
detail: {
change_type: ChangeType.Added,
organization_id: 'organization-id',
project_id: 'project-id'
}
})
);
await tick();

expect(organizationRefetch).not.toHaveBeenCalled();
expect(projectsRefetch).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export function isEntityChangedType(message: { message: unknown; type: WebSocket
return message.type !== 'PlanChanged' && message.type !== 'UserMembershipChanged' && message.type.endsWith('Changed');
}

export function isPlanOverageType(message: { message: unknown; type: WebSocketMessageType }): message is WebSocketMessage<'PlanOverage'> {
return message.type === 'PlanOverage';
}

export function isWebSocketMessageType(type: string): type is WebSocketMessageType {
return (
(['PlanChanged', 'PlanOverage', 'UserMembershipChanged', 'ReleaseNotification', 'SystemNotification'] as const).includes(
Expand Down
13 changes: 10 additions & 3 deletions src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
import { buildIntercomBootOptions, IntercomShell } from '$features/intercom';
import { shouldLoadIntercomOrganization } from '$features/intercom/config';
import Notifications from '$features/notifications/components/notifications.svelte';
import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte';
import {
getOrganizationQuery,
getOrganizationsQuery,
invalidateOrganizationQueries,
invalidatePlanOverageQueries
} from '$features/organizations/api.svelte';
import OrganizationNotifications from '$features/organizations/components/organization-notifications.svelte';
import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte';
import { premiumPage } from '$features/organizations/premium-page.svelte';
Expand All @@ -28,7 +33,7 @@
import { getMeQuery, invalidateUserQueries } from '$features/users/api.svelte';
import { getGravatarFromCurrentUser } from '$features/users/gravatar.svelte';
import { invalidateWebhookQueries } from '$features/webhooks/api.svelte';
import { isEntityChangedType, type WebSocketMessageType } from '$features/websockets/models';
import { isEntityChangedType, isPlanOverageType, type WebSocketMessageType } from '$features/websockets/models';
import { WebSocketClient } from '$features/websockets/web-socket-client.svelte';
import { Telemetry } from '$lib/telemetry';
import { useMiddleware } from '@foundatiofx/fetchclient';
Expand Down Expand Up @@ -129,7 +134,9 @@
})
);

if (isEntityChangedType(data)) {
if (isPlanOverageType(data)) {
await invalidatePlanOverageQueries(queryClient, data.message);
} else if (isEntityChangedType(data)) {
switch (data.type) {
case 'OrganizationChanged':
await invalidateOrganizationQueries(queryClient, data.message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Billing;
using Exceptionless.Core.Repositories;
using Exceptionless.Core.Services;
using Exceptionless.Core.Utility;
using Exceptionless.Tests.Extensions;
using Exceptionless.Tests.Utility;
Expand Down Expand Up @@ -575,6 +576,34 @@ public async Task GetAsync_ViewOrganization_IncludesIsOverMonthlyLimit()
Assert.False(viewOrg.IsOverMonthlyLimit);
}

[Fact]
public async Task GetAsync_WithPendingUsageAtMonthlyLimit_ReturnsRealTimeOverageState()
{
// Arrange
var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID);
Assert.NotNull(organization);

organization.MaxEventsPerMonth = 1;
organization.Usage.Clear();
organization.UsageHours.Clear();
await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals());

var usageService = GetService<UsageService>();
await usageService.IncrementTotalAsync(organization.Id, SampleDataService.TEST_PROJECT_ID);

// Act
var viewOrganization = await SendRequestAsAsync<ViewOrganization>(r => r
.AsTestOrganizationUser()
.AppendPaths("organizations", organization.Id)
.StatusCodeShouldBeOk()
);

// Assert
Assert.NotNull(viewOrganization);
Assert.Equal(1, viewOrganization.GetCurrentUsage(TimeProvider).Total);
Assert.True(viewOrganization.IsOverMonthlyLimit);
}

[Fact]
public async Task PostAsync_NewOrganization_SetsCreatedAndUpdatedDates()
{
Expand Down
Loading
Loading