Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
10 changes: 6 additions & 4 deletions src/Exceptionless.Core/Services/UsageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ await _cache.RemoveAllAsync(new[] {
});

await _cache.SetAsync(GetTotalCacheKey(utcNow, organizationId), usage.Total, TimeSpan.FromHours(8));
await _organizationRepository.SaveAsync(organization);
// Usage counters and last-event timestamps are operational updates, not user-facing entity changes.
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 Invalidate organization state on plan overage

When this usage save is the one that crosses the monthly or hourly limit, the backend can still emit PlanOverage, but the Svelte client does not refetch organization data for that message: src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte only invalidates queries inside isEntityChangedType, and PlanOverage is not a *Changed entity message. Before this change, the accompanying OrganizationChanged from this SaveAsync refreshed is_over_monthly_limit / is_throttled; with Notifications(false), connected users can remain on stale organization state and miss the overage banner until another organization change or a reload. Please keep an organization invalidation for overage transitions or add a PlanOverage handler in the client.

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 f8449f0: the Svelte websocket handler now recognizes PlanOverage and invalidates only the affected organization, organization stats, and organization list queries. The endpoint also evaluates pending usage so the refetch observes the transition. Covered by the targeted frontend query-invalidation test and OrganizationEndpointTests.

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 Keep clearing throttled state visible to clients

When an organization hits the hourly bucket limit, the new PlanOverage handler refreshes the Svelte organization query to is_throttled: true, but this save is also where the completed bucket's throttled cache key is removed; with repository notifications disabled here, the previous OrganizationChanged refetch that cleared the hourly overage banner is no longer emitted. In an active session that does not remount or focus-refetch after the 5-minute throttle window, users can keep seeing the destructive hourly overage banner even though GetUsageAsync would now return IsThrottled = false; please keep a clear-side invalidation/notification for this state transition.

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 15e27df: hourly throttle transitions now write a per-bucket marker before PlanOverage is published. Saving that completed bucket emits one OrganizationChanged while routine usage saves remain silent. Covered by SavePendingUsageAsync_HourlyThrottleClears_PublishesOrganizationChangedMessage; the full UsageServiceTests suite passes.

}
}

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

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

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