Browse Source

Measure traffic.

pull/491/head
Sebastian 6 years ago
parent
commit
9fb53c7608
  1. 1
      backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  2. 42
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  3. 6
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  4. 1
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs
  5. 1
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs
  6. 6
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  7. 1
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs
  8. 18
      backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs
  9. 103
      backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs
  10. 127
      backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  11. 18
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  12. 45
      backend/src/Squidex.Infrastructure/UsageTracking/Counters.cs
  13. 22
      backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs
  14. 8
      backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  15. 29
      backend/src/Squidex.Infrastructure/UsageTracking/Usage.cs
  16. 1
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs
  17. 1
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  18. 1
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  19. 17
      backend/src/Squidex.Web/IAppFeature.cs
  20. 46
      backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  21. 11
      backend/src/Squidex.Web/Pipeline/AppFeature.cs
  22. 9
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  23. 5
      backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs
  24. 2
      backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs
  25. 97
      backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
  26. 62
      backend/src/Squidex.Web/Pipeline/UsagePipeWriter.cs
  27. 80
      backend/src/Squidex.Web/Pipeline/UsageResponseBodyFeature.cs
  28. 130
      backend/src/Squidex.Web/Pipeline/UsageStream.cs
  29. 25
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/ApiUsageDto.cs
  30. 56
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/ApiUsagesDto.cs
  31. 34
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  32. 2
      backend/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs
  33. 2
      backend/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs
  34. 1
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs
  35. 2
      backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs
  36. 2
      backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs
  37. 3
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  38. 2
      backend/src/Squidex/Config/Web/WebExtensions.cs
  39. 5
      backend/src/Squidex/Config/Web/WebServices.cs
  40. 2
      backend/src/Squidex/Pipeline/Squid/SquidMiddleware.cs
  41. 6
      backend/src/Squidex/Startup.cs
  42. 131
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs
  43. 201
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs
  44. 69
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs
  45. 1
      backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs
  46. 78
      backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs
  47. 2
      backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs
  48. 163
      backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs
  49. 32
      frontend/app/features/dashboard/pages/dashboard-page.component.html
  50. 82
      frontend/app/features/dashboard/pages/dashboard-page.component.ts
  51. 71
      frontend/app/shared/services/usages.service.spec.ts
  52. 55
      frontend/app/shared/services/usages.service.ts

1
backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -7,7 +7,6 @@
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps.Plans;
namespace Squidex.Domain.Apps.Entities.Apps
{

42
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
@ -19,51 +18,54 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public partial class AssetUsageTracker : IAssetUsageTracker, IEventConsumer
{
private const string Category = "Default";
private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate;
private readonly IUsageRepository usageStore;
private readonly IUsageTracker usageTracker;
public AssetUsageTracker(IUsageRepository usageStore)
public AssetUsageTracker(IUsageTracker usageTracker)
{
Guard.NotNull(usageStore);
Guard.NotNull(usageTracker);
this.usageStore = usageStore;
this.usageTracker = usageTracker;
}
public async Task<long> GetTotalSizeAsync(Guid appId)
{
var key = GetKey(appId);
var entries = await usageStore.QueryAsync(key, SummaryDate, SummaryDate);
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate);
return (long)entries.Select(x => x.Counters.Get(CounterTotalSize)).FirstOrDefault();
return counters.GetInt64(CounterTotalSize);
}
public async Task<IReadOnlyList<AssetStats>> QueryAsync(Guid appId, DateTime fromDate, DateTime toDate)
{
var enriched = new List<AssetStats>();
var usagesFlat = await usageStore.QueryAsync(GetKey(appId), fromDate, toDate);
var usages = await usageTracker.QueryAsync(GetKey(appId), fromDate, toDate);
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
if (usages.TryGetValue("*", out var byCategory1))
{
var stored = usagesFlat.FirstOrDefault(x => x.Date == date && x.Category == Category);
AddCounters(enriched, byCategory1);
}
else if (usages.TryGetValue("Default", out var byCategory2))
{
AddCounters(enriched, byCategory2);
}
var totalCount = 0L;
var totalSize = 0L;
return enriched;
}
if (stored != null)
{
totalCount = (long)stored.Counters.Get(CounterTotalCount);
totalSize = (long)stored.Counters.Get(CounterTotalSize);
}
private static void AddCounters(List<AssetStats> enriched, List<(DateTime Date, Counters Counters)>? byCategory)
{
foreach (var (date, counters) in byCategory)
{
var totalCount = counters.GetInt64(CounterTotalCount);
var totalSize = counters.GetInt64(CounterTotalSize);
enriched.Add(new AssetStats(date, totalCount, totalSize));
}
return enriched;
}
}
}

6
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -65,11 +65,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
[CounterTotalCount] = count
};
var key = GetKey(appId);
var appKey = GetKey(appId);
return Task.WhenAll(
usageStore.TrackUsagesAsync(new UsageUpdate(date, key, Category, counters)),
usageStore.TrackUsagesAsync(new UsageUpdate(SummaryDate, key, Category, counters)));
usageTracker.TrackAsync(date, appKey, null, counters),
usageTracker.TrackAsync(SummaryDate, appKey, null, counters));
}
private static string GetKey(Guid appId)

1
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsSearchSource.cs

@ -7,6 +7,7 @@
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;

1
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsSearchSource.cs

@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;

6
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs

@ -24,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
public sealed class UsageTrackerGrain : GrainOfString, IRemindable, IUsageTrackerGrain
{
private readonly IGrainState<GrainState> state;
private readonly IUsageTracker usageTracker;
private readonly IApiUsageTracker usageTracker;
public sealed class Target
{
@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
public Dictionary<Guid, Target> Targets { get; set; } = new Dictionary<Guid, Target>();
}
public UsageTrackerGrain(IGrainState<GrainState> state, IUsageTracker usageTracker)
public UsageTrackerGrain(IGrainState<GrainState> state, IApiUsageTracker usageTracker)
{
Guard.NotNull(state);
Guard.NotNull(usageTracker);
@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
if (!target.Triggered.HasValue || target.Triggered < from)
{
var usage = await usageTracker.GetMonthlyCallsAsync(target.AppId.Id.ToString(), today);
var usage = await usageTracker.GetMonthlyWeightAsync(target.AppId.Id.ToString(), today);
var limit = target.Limits;

1
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasSearchSource.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Search;
using Squidex.Infrastructure;
using Squidex.Shared;

18
backend/src/Squidex.Infrastructure/UsageTracking/DateUsage.cs → backend/src/Squidex.Infrastructure/UsageTracking/ApiStats.cs

@ -5,24 +5,22 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class DateUsage
public sealed class ApiStats
{
public DateTime Date { get; }
public long TotalCalls { get; }
public long TotalCount { get; }
public long TotalBytes { get; }
public long TotalElapsedMs { get; }
public double AverageElapsed { get; }
public DateUsage(DateTime date, long totalCount, long totalElapsedMs)
public ApiStats(long totalCalls, double averageElapsed, long totalBytes)
{
Date = date;
TotalCalls = totalCalls;
TotalBytes = totalBytes;
TotalCount = totalCount;
TotalElapsedMs = totalElapsedMs;
AverageElapsed = averageElapsed;
}
}
}

103
backend/src/Squidex.Infrastructure/UsageTracking/ApiUsageTracker.cs

@ -0,0 +1,103 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class ApiUsageTracker : IApiUsageTracker
{
public const string CounterTotalBytes = "TotalBytes";
public const string CounterTotalWeight = "TotalWeight";
public const string CounterTotalCalls = "TotalCalls";
public const string CounterTotalElapsedMs = "TotalElapsedMs";
private readonly IUsageTracker usageTracker;
public ApiUsageTracker(IUsageTracker usageTracker)
{
this.usageTracker = usageTracker;
}
public async Task<long> GetMonthlyWeightAsync(string key, DateTime date)
{
var apiKey = GetKey(key);
var counters = await usageTracker.GetForMonthAsync(apiKey, date);
return counters.GetInt64(CounterTotalWeight);
}
public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsed, long bytes)
{
var apiKey = GetKey(key);
var counters = new Counters
{
[CounterTotalWeight] = weight,
[CounterTotalCalls] = 1,
[CounterTotalElapsedMs] = elapsed,
[CounterTotalBytes] = bytes
};
return usageTracker.TrackAsync(date, apiKey, category, counters);
}
public async Task<(ApiStats Summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
var apiKey = GetKey(key);
var queries = await usageTracker.QueryAsync(apiKey, fromDate, toDate);
var result = new Dictionary<string, List<(DateTime Date, ApiStats Stats)>>();
var summaryBytes = 0L;
var summaryCalls = 0L;
var summaryElapsed = 0L;
foreach (var (category, usages) in queries)
{
var resultByCategory = new List<(DateTime Date, ApiStats)>();
foreach (var usage in usages)
{
var dateBytes = usage.Counters.GetInt64(CounterTotalBytes);
var dateCalls = usage.Counters.GetInt64(CounterTotalCalls);
var dateElapsed = usage.Counters.GetInt64(CounterTotalElapsedMs);
var dateElapsedAvg = CalculateAverage(dateCalls, dateElapsed);
resultByCategory.Add((usage.Date, new ApiStats(dateCalls, dateElapsedAvg, dateBytes)));
summaryBytes += dateBytes;
summaryCalls += dateCalls;
summaryElapsed += dateElapsed;
}
result[category] = resultByCategory;
}
var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed);
var summary = new ApiStats(summaryCalls, summaryElapsedAvg, summaryBytes);
return (summary, result);
}
private static double CalculateAverage(long calls, long elapsed)
{
return calls > 0 ? Math.Round((double)elapsed / calls, 2) : 0;
}
private static string GetKey(string key)
{
Guard.NotNullOrEmpty(key);
return $"{key}_API";
}
}
}

127
backend/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -20,32 +20,12 @@ namespace Squidex.Infrastructure.UsageTracking
{
public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker
{
public const string CounterTotalCalls = "TotalCalls";
public const string CounterTotalElapsedMs = "TotalElapsedMs";
public const string FallbackCategory = "*";
private const int Intervall = 60 * 1000;
private const string FallbackCategory = "*";
private readonly IUsageRepository usageRepository;
private readonly ISemanticLog log;
private readonly CompletionTimer timer;
private ConcurrentDictionary<(string Key, string Category), Usage> usages = new ConcurrentDictionary<(string Key, string Category), Usage>();
private struct Usage
{
public readonly double Count;
public readonly double ElapsedMs;
public Usage(double elapsed, double count)
{
ElapsedMs = elapsed;
Count = count;
}
public Usage Add(double elapsed, double weight)
{
return new Usage(ElapsedMs + elapsed, Count + weight);
}
}
private ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters> jobs = new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>();
public BackgroundUsageTracker(IUsageRepository usageRepository, ISemanticLog log)
{
@ -78,9 +58,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
try
{
var today = DateTime.Today;
var localUsages = Interlocked.Exchange(ref usages, new ConcurrentDictionary<(string Key, string Category), Usage>());
var localUsages = Interlocked.Exchange(ref jobs, new ConcurrentDictionary<(string Key, string Category, DateTime Date), Counters>());
if (localUsages.Count > 0)
{
@ -89,16 +67,10 @@ namespace Squidex.Infrastructure.UsageTracking
foreach (var (key, value) in localUsages)
{
var counters = new Counters
{
[CounterTotalCalls] = value.Count,
[CounterTotalElapsedMs] = value.ElapsedMs
};
updates[updateIndex].Key = key.Key;
updates[updateIndex].Category = key.Category;
updates[updateIndex].Counters = counters;
updates[updateIndex].Date = today;
updates[updateIndex].Counters = value;
updates[updateIndex].Date = key.Date;
updateIndex++;
}
@ -114,101 +86,90 @@ namespace Squidex.Infrastructure.UsageTracking
}
}
public Task TrackAsync(string key, string? category, double weight, double elapsedMs)
public Task TrackAsync(DateTime date, string key, string? category, Counters counters)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
Guard.NotNull(counters);
ThrowIfDisposed();
if (weight > 0)
{
category = GetCategory(category);
category = GetCategory(category);
usages.AddOrUpdate((key, category), _ => new Usage(elapsedMs, weight), (k, x) => x.Add(elapsedMs, weight));
}
jobs.AddOrUpdate((key, category, date), counters, (k, p) => p.Aggregate(counters));
return Task.CompletedTask;
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
public async Task<Dictionary<string, List<(DateTime Date, Counters Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
ThrowIfDisposed();
var usagesFlat = await usageRepository.QueryAsync(key, fromDate, toDate);
var usagesByCategory = usagesFlat.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList());
var usages = await usageRepository.QueryAsync(key, fromDate, toDate);
var result = new Dictionary<string, IReadOnlyList<DateUsage>>();
var result = new Dictionary<string, List<(DateTime Date, Counters Counters)>>();
if (usagesByCategory.Count == 0)
var categories = usages.GroupBy(x => GetCategory(x.Category)).ToDictionary(x => x.Key, x => x.ToList());
if (categories.Keys.Count == 0)
{
var enriched = new List<DateUsage>();
var enriched = new List<(DateTime Date, Counters Counters)>();
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
enriched.Add(new DateUsage(date, 0, 0));
enriched.Add((date, new Counters()));
}
result[FallbackCategory] = enriched;
}
else
{
foreach (var category in usagesByCategory.Keys)
{
var enriched = new List<DateUsage>();
var usagesDictionary = usagesByCategory[category].ToDictionary(x => x.Date);
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
var stored = usagesDictionary.GetOrDefault(date);
var totalCount = 0L;
var totalElapsedMs = 0L;
if (stored != null)
{
totalCount = (long)stored.Counters.Get(CounterTotalCalls);
totalElapsedMs = (long)stored.Counters.Get(CounterTotalElapsedMs);
}
foreach (var (category, value) in categories)
{
var enriched = new List<(DateTime Date, Counters Counters)>();
enriched.Add(new DateUsage(date, totalCount, totalElapsedMs));
}
for (var date = fromDate; date <= toDate; date = date.AddDays(1))
{
var counters = value.FirstOrDefault(x => x.Date == date)?.Counters;
result[category] = enriched;
enriched.Add((date, counters ?? new Counters()));
}
result[category] = enriched;
}
return result;
}
public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
public Task<Counters> GetForMonthAsync(string key, DateTime date)
{
return GetPreviousCallsAsync(key, new DateTime(date.Year, date.Month, 1), date);
var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1);
return GetAsync(key, dateFrom, dateTo);
}
public async Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
public async Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate)
{
key = GetKey(key);
Guard.NotNullOrEmpty(key);
ThrowIfDisposed();
var originalUsages = await usageRepository.QueryAsync(key, fromDate, toDate);
var queried = await usageRepository.QueryAsync(key, fromDate, toDate);
return originalUsages.Sum(x => (long)x.Counters.Get(CounterTotalCalls));
var result = new Counters();
foreach (var usage in queried)
{
result.MergeIn(usage.Counters);
}
return result;
}
private static string GetCategory(string? category)
{
return !string.IsNullOrWhiteSpace(category) ? category.Trim() : FallbackCategory;
}
private static string GetKey(string key)
{
Guard.NotNull(key);
return $"{key}_API";
}
}
}

18
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -26,45 +26,45 @@ namespace Squidex.Infrastructure.UsageTracking
this.inner = inner;
}
public Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
public Task<Dictionary<string, List<(DateTime Date, Counters Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate)
{
Guard.NotNull(key);
return inner.QueryAsync(key, fromDate, toDate);
}
public Task TrackAsync(string key, string? category, double weight, double elapsedMs)
public Task TrackAsync(DateTime date, string key, string? category, Counters counters)
{
Guard.NotNull(key);
return inner.TrackAsync(key, category, weight, elapsedMs);
return inner.TrackAsync(date, key, category, counters);
}
public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
public Task<Counters> GetForMonthAsync(string key, DateTime date)
{
Guard.NotNull(key);
var cacheKey = string.Join("$", "Usage", nameof(GetMonthlyCallsAsync), key, date);
var cacheKey = string.Join("$", "Usage", nameof(GetForMonthAsync), key, date);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetMonthlyCallsAsync(key, date);
return inner.GetForMonthAsync(key, date);
});
}
public Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
public Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate)
{
Guard.NotNull(key);
var cacheKey = string.Join("$", "Usage", nameof(GetPreviousCallsAsync), key, fromDate, toDate);
var cacheKey = string.Join("$", "Usage", nameof(GetAsync), key, fromDate, toDate);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetPreviousCallsAsync(key, fromDate, toDate);
return inner.GetAsync(key, fromDate, toDate);
});
}
}

45
backend/src/Squidex.Infrastructure/UsageTracking/Counters.cs

@ -11,6 +11,15 @@ namespace Squidex.Infrastructure.UsageTracking
{
public sealed class Counters : Dictionary<string, double>
{
public Counters()
{
}
public Counters(Counters source)
: base(source)
{
}
public double Get(string name)
{
if (name == null)
@ -22,5 +31,41 @@ namespace Squidex.Infrastructure.UsageTracking
return value;
}
public long GetInt64(string name)
{
if (name == null)
{
return 0;
}
TryGetValue(name, out var value);
return (long)value;
}
public Counters Aggregate(Counters counters)
{
var result = new Counters(this);
result.MergeIn(counters);
return result;
}
public void MergeIn(Counters counters)
{
foreach (var (key, value) in counters)
{
var newValue = value;
if (TryGetValue(key, out var temp))
{
newValue += temp;
}
this[key] = newValue;
}
}
}
}

22
backend/src/Squidex.Infrastructure/UsageTracking/IApiUsageTracker.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.UsageTracking
{
public interface IApiUsageTracker
{
Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsed, long bytes);
Task<long> GetMonthlyWeightAsync(string key, DateTime date);
Task<(ApiStats Summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate);
}
}

8
backend/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs

@ -13,12 +13,12 @@ namespace Squidex.Infrastructure.UsageTracking
{
public interface IUsageTracker
{
Task TrackAsync(string key, string? category, double weight, double elapsedMs);
Task TrackAsync(DateTime date, string key, string? category, Counters counters);
Task<long> GetMonthlyCallsAsync(string key, DateTime date);
Task<Counters> GetForMonthAsync(string key, DateTime date);
Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate);
Task<Counters> GetAsync(string key, DateTime fromDate, DateTime toDate);
Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
Task<Dictionary<string, List<(DateTime Date, Counters Counters)>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
}
}

29
backend/src/Squidex.Infrastructure/UsageTracking/Usage.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1401 // Fields must be private
namespace Squidex.Infrastructure.UsageTracking
{
public sealed class Usage
{
public readonly double Count;
public readonly double ElapsedMs;
public Usage(double elapsed, double count)
{
ElapsedMs = elapsed;
Count = count;
}
public Usage Add(double elapsed, double weight)
{
return new Usage(ElapsedMs + elapsed, Count + weight);
}
}
}

1
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities;

1
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs

@ -8,6 +8,7 @@
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;

1
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure;

17
backend/src/Squidex.Web/IAppFeature.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Web
{
public interface IAppFeature
{
NamedId<Guid> AppId { get; }
}
}

46
backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -9,12 +9,9 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
@ -22,23 +19,16 @@ namespace Squidex.Web.Pipeline
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{
private readonly IAppPlansProvider appPlansProvider;
private readonly IAppLogStore appLogStore;
private readonly IUsageTracker usageTracker;
private readonly IClock clock;
private readonly IApiUsageTracker usageTracker;
public ApiCostsFilter(IAppLogStore appLogStore, IAppPlansProvider appPlansProvider, IUsageTracker usageTracker, IClock clock)
public ApiCostsFilter(IAppPlansProvider appPlansProvider, IApiUsageTracker usageTracker)
{
Guard.NotNull(appLogStore);
Guard.NotNull(appPlansProvider);
Guard.NotNull(usageTracker);
Guard.NotNull(clock);
this.appLogStore = appLogStore;
this.appPlansProvider = appPlansProvider;
this.usageTracker = usageTracker;
this.clock = clock;
}
IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@ -71,7 +61,7 @@ namespace Squidex.Web.Pipeline
{
var (plan, _) = appPlansProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCallsAsync(appId, DateTime.Today);
var usage = await usageTracker.GetMonthlyWeightAsync(appId, DateTime.Today);
if (plan.BlockingApiCalls >= 0 && usage > plan.BlockingApiCalls)
{
@ -80,35 +70,9 @@ namespace Squidex.Web.Pipeline
}
}
}
var watch = ValueStopwatch.StartNew();
try
{
await next();
}
finally
{
var elapsedMs = watch.Stop();
await appLogStore.LogAsync(app.Id, clock.GetCurrentInstant(),
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.User.OpenIdSubject(),
context.HttpContext.User.OpenIdClientId(),
elapsedMs,
FilterDefinition.Weight);
if (FilterDefinition.Weight > 0)
{
await usageTracker.TrackAsync(appId, context.HttpContext.User.OpenIdClientId(), FilterDefinition.Weight, elapsedMs);
}
}
}
else
{
await next();
}
await next();
}
}
}

11
backend/src/Squidex.Domain.Apps.Entities/DomainEntityExtensions.cs → backend/src/Squidex.Web/Pipeline/AppFeature.cs

@ -6,16 +6,17 @@
// ==========================================================================
using System;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
namespace Squidex.Web.Pipeline
{
public static class DomainEntityExtensions
public sealed class AppFeature : IAppFeature
{
public static NamedId<Guid> NamedId(this IAppEntity entity)
public NamedId<Guid> AppId { get; }
public AppFeature(NamedId<Guid> appId)
{
return new NamedId<Guid>(entity.Id, entity.Name);
AppId = appId;
}
}
}

9
backend/src/Squidex.Web/Pipeline/AppResolver.cs

@ -74,16 +74,23 @@ namespace Squidex.Web.Pipeline
requestContext.App = app;
requestContext.UpdatePermissions();
if (!requestContext.Permissions.Includes(Permissions.ForApp(Permissions.App, appName)) && !AllowAnonymous(context))
if (!AllowAnonymous(context) && !HasPermission(appName, requestContext))
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set<IAppFeature>(new AppFeature(app.NamedId()));
}
await next();
}
private static bool HasPermission(string appName, Context requestContext)
{
return requestContext.Permissions.Includes(Permissions.ForApp(Permissions.App, appName));
}
private static bool AllowAnonymous(ActionExecutingContext context)
{
return context.ActionDescriptor.EndpointMetadata.Any(x => x is AllowAnonymousAttribute);

5
backend/src/Squidex.Web/Pipeline/CleanupHostMiddleware.cs

@ -7,7 +7,6 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure;
namespace Squidex.Web.Pipeline
{
@ -17,12 +16,10 @@ namespace Squidex.Web.Pipeline
public CleanupHostMiddleware(RequestDelegate next)
{
Guard.NotNull(next);
this.next = next;
}
public Task Invoke(HttpContext context)
public Task InvokeAsync(HttpContext context)
{
var request = context.Request;

2
backend/src/Squidex.Web/Pipeline/RequestExceptionMiddleware.cs

@ -9,8 +9,6 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security;
namespace Squidex.Web.Pipeline
{

97
backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs

@ -0,0 +1,97 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
{
public sealed class UsageMiddleware : IMiddleware
{
private readonly IAppLogStore log;
private readonly IApiUsageTracker usageTracker;
private readonly IClock clock;
public UsageMiddleware(IAppLogStore log, IApiUsageTracker usageTracker, IClock clock)
{
Guard.NotNull(log);
Guard.NotNull(usageTracker);
Guard.NotNull(clock);
this.log = log;
this.usageTracker = usageTracker;
this.clock = clock;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var usageBody = SetUsageBody(context);
var watch = ValueStopwatch.StartNew();
try
{
await next(context);
}
finally
{
if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests)
{
var app = context.Features.Get<IAppFeature>()?.AppId;
var costs = context.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
if (app != null)
{
var elapsedMs = watch.Stop();
var now = clock.GetCurrentInstant();
var userId = context.User.OpenIdSubject();
var userClient = context.User.OpenIdClientId();
await log.LogAsync(app.Id, now,
context.Request.Method,
context.Request.Path,
userId,
userClient,
elapsedMs,
costs);
if (costs > 0)
{
var bytes = usageBody.BytesWritten + (context.Request.ContentLength ?? 0);
var date = now.ToDateTimeUtc().Date;
await usageTracker.TrackAsync(date, app.Id.ToString(), userClient, costs, elapsedMs, bytes);
}
}
}
}
}
private static UsageResponseBodyFeature SetUsageBody(HttpContext context)
{
var originalBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
var usageBody = new UsageResponseBodyFeature(originalBodyFeature);
context.Features.Set<IHttpResponseBodyFeature>(usageBody);
return usageBody;
}
}
}

62
backend/src/Squidex.Web/Pipeline/UsagePipeWriter.cs

@ -0,0 +1,62 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Web.Pipeline
{
public sealed class UsagePipeWriter : PipeWriter
{
private readonly PipeWriter inner;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten; }
}
public UsagePipeWriter(PipeWriter inner)
{
this.inner = inner;
}
public override void Advance(int bytes)
{
inner.Advance(bytes);
bytesWritten += bytes;
}
public override void CancelPendingFlush()
{
inner.CancelPendingFlush();
}
public override void Complete(Exception? exception = null)
{
inner.Complete();
}
public override ValueTask<FlushResult> FlushAsync(CancellationToken cancellationToken = default)
{
return inner.FlushAsync(cancellationToken);
}
public override Memory<byte> GetMemory(int sizeHint = 0)
{
return inner.GetMemory(sizeHint);
}
public override Span<byte> GetSpan(int sizeHint = 0)
{
return inner.GetSpan(sizeHint);
}
}
}

80
backend/src/Squidex.Web/Pipeline/UsageResponseBodyFeature.cs

@ -0,0 +1,80 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.IO;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http.Features;
namespace Squidex.Web.Pipeline
{
internal sealed class UsageResponseBodyFeature : IHttpResponseBodyFeature
{
private readonly IHttpResponseBodyFeature inner;
private readonly UsageStream usageStream;
private readonly UsagePipeWriter usageWriter;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten + usageStream.BytesWritten + usageWriter.BytesWritten; }
}
public Stream Stream
{
get { return usageStream; }
}
public PipeWriter Writer
{
get { return usageWriter; }
}
public UsageResponseBodyFeature(IHttpResponseBodyFeature inner)
{
usageStream = new UsageStream(inner.Stream);
usageWriter = new UsagePipeWriter(inner.Writer);
this.inner = inner;
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
return inner.StartAsync(cancellationToken);
}
public Task CompleteAsync()
{
return inner.CompleteAsync();
}
public void DisableBuffering()
{
inner.DisableBuffering();
}
public async Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default)
{
await inner.SendFileAsync(path, offset, count, cancellationToken);
if (count != null)
{
bytesWritten += count.Value;
}
else
{
var file = new FileInfo(path);
if (file.Exists)
{
bytesWritten += file.Length;
}
}
}
}
}

130
backend/src/Squidex.Web/Pipeline/UsageStream.cs

@ -0,0 +1,130 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Web.Pipeline
{
internal sealed class UsageStream : Stream
{
private readonly Stream inner;
private long bytesWritten;
public long BytesWritten
{
get { return bytesWritten; }
}
public override bool CanRead
{
get { return false; }
}
public override bool CanSeek
{
get { return false; }
}
public override bool CanWrite
{
get { return inner.CanWrite; }
}
public override long Length
{
get { throw new NotSupportedException(); }
}
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public UsageStream(Stream inner)
{
this.inner = inner;
}
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object? state)
{
var result = inner.BeginWrite(buffer, offset, count, callback, state);
bytesWritten += count;
return result;
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
await base.WriteAsync(buffer, offset, count, cancellationToken);
bytesWritten += count;
}
public override void Write(byte[] buffer, int offset, int count)
{
inner.Write(buffer, offset, count);
bytesWritten += count;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
await base.WriteAsync(buffer, cancellationToken);
bytesWritten += buffer.Length;
}
public override void Write(ReadOnlySpan<byte> buffer)
{
inner.Write(buffer);
bytesWritten += buffer.Length;
}
public override void WriteByte(byte value)
{
inner.WriteByte(value);
bytesWritten++;
}
public override Task FlushAsync(CancellationToken cancellationToken)
{
return inner.FlushAsync(cancellationToken);
}
public override void Flush()
{
inner.Flush();
}
public override void EndWrite(IAsyncResult asyncResult)
{
inner.EndWrite(asyncResult);
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
}
}

25
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs → backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/ApiUsageDto.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class CallsUsageDto
public sealed class ApiUsageDto
{
/// <summary>
/// The date when the usage was tracked.
@ -18,20 +18,31 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models
public DateTime Date { get; set; }
/// <summary>
/// The number of calls.
/// The total number of API calls.
/// </summary>
public long Count { get; set; }
public long TotalCalls { get; set; }
/// <summary>
/// The total number of bytes transferred.
/// </summary>
public long TotalBytes { get; set; }
/// <summary>
/// The average duration in milliseconds.
/// </summary>
public long AverageMs { get; set; }
public double AverageMs { get; set; }
public static CallsUsageDto FromUsage(DateUsage usage)
public static ApiUsageDto FromUsage((DateTime Date, ApiStats Stats) dateStatistics)
{
var averageMs = usage.TotalCount == 0 ? 0 : usage.TotalElapsedMs / usage.TotalCount;
var (date, stats) = dateStatistics;
return new CallsUsageDto { Date = usage.Date, Count = usage.TotalCount, AverageMs = averageMs };
return new ApiUsageDto
{
Date = date,
TotalBytes = stats.TotalBytes,
TotalCalls = stats.TotalCalls,
AverageMs = stats.AverageElapsed,
};
}
}
}

56
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/ApiUsagesDto.cs

@ -0,0 +1,56 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Areas.Api.Controllers.Statistics.Models
{
public sealed class ApiUsagesDto
{
/// <summary>
/// The total number of API calls.
/// </summary>
public long TotalCalls { get; set; }
/// <summary>
/// The total number of bytes transferred.
/// </summary>
public long TotalBytes { get; set; }
/// <summary>
/// The allowed API calls.
/// </summary>
public long AllowedCalls { get; set; }
/// <summary>
/// The average duration in milliseconds.
/// </summary>
public double AverageMs { get; set; }
/// <summary>
/// The statistics by date and group.
/// </summary>
[Required]
public Dictionary<string, ApiUsageDto[]> Details { get; set; }
public static ApiUsagesDto FromUsages(long allowedCalls, ApiStats summary, Dictionary<string, List<(DateTime Date, ApiStats Stats)>> details)
{
return new ApiUsagesDto
{
AllowedCalls = allowedCalls,
AverageMs = summary.AverageElapsed,
TotalBytes = summary.TotalBytes,
TotalCalls = summary.TotalBytes,
Details = details.ToDictionary(x => x.Key, x => x.Value.Select(ApiUsageDto.FromUsage).ToArray())
};
}
}
}

34
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -29,7 +29,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
[ApiExplorerSettings(GroupName = nameof(Statistics))]
public sealed class UsagesController : ApiController
{
private readonly IUsageTracker usageTracker;
private readonly IApiUsageTracker usageTracker;
private readonly IAppLogStore appLogStore;
private readonly IAppPlansProvider appPlansProvider;
private readonly IAssetUsageTracker assetStatsRepository;
@ -38,7 +38,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
public UsagesController(
ICommandBus commandBus,
IUsageTracker usageTracker,
IApiUsageTracker usageTracker,
IAppLogStore appLogStore,
IAppPlansProvider appPlansProvider,
IAssetUsageTracker assetStatsRepository,
@ -80,30 +80,6 @@ namespace Squidex.Areas.Api.Controllers.Statistics
return Ok(response);
}
/// <summary>
/// Get api calls for this month.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Usage tracking results returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/calls/month/")]
[ProducesResponseType(typeof(CurrentCallsDto), 200)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> GetMonthlyCalls(string app)
{
var count = await usageTracker.GetMonthlyCallsAsync(AppId.ToString(), DateTime.Today);
var (plan, _) = appPlansProvider.GetPlanForApp(App);
var response = new CurrentCallsDto { Count = count, MaxAllowed = plan.MaxApiCalls };
return Ok(response);
}
/// <summary>
/// Get api calls in date range.
/// </summary>
@ -117,7 +93,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics
/// </returns>
[HttpGet]
[Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(Dictionary<string, CallsUsageDto[]>), 200)]
[ProducesResponseType(typeof(ApiUsagesDto), 200)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> GetUsages(string app, DateTime fromDate, DateTime toDate)
@ -129,7 +105,9 @@ namespace Squidex.Areas.Api.Controllers.Statistics
var usages = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date);
var response = usages.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsageDto.FromUsage).ToArray());
var (plan, _) = appPlansProvider.GetPlanForApp(App);
var response = ApiUsagesDto.FromUsages(plan.MaxApiCalls, usages.Summary, usages.Details);
return Ok(response);
}

2
backend/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs

@ -21,7 +21,7 @@ namespace Squidex.Areas.Frontend.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
if (context.IsHtmlPath() && context.Response.StatusCode != 304)
{

2
backend/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs

@ -24,7 +24,7 @@ namespace Squidex.Areas.Frontend.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
if (context.IsIndex() && context.Response.StatusCode != 304)
{

1
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs

@ -9,7 +9,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;

2
backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs

@ -27,7 +27,7 @@ namespace Squidex.Areas.OrleansDashboard.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var authentication = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

2
backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs

@ -22,7 +22,7 @@ namespace Squidex.Areas.Portal.Middlewares
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var authentication = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

3
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -71,6 +71,9 @@ namespace Squidex.Config.Domain
c.GetRequiredService<IMemoryCache>()))
.As<IUsageTracker>();
services.AddSingletonAs<ApiUsageTracker>()
.As<IApiUsageTracker>();
services.AddSingletonAs<BackgroundUsageTracker>()
.AsSelf();

2
backend/src/Squidex/Config/Web/WebExtensions.cs

@ -36,6 +36,7 @@ namespace Squidex.Config.Web
public static IApplicationBuilder UseSquidexTracking(this IApplicationBuilder app)
{
app.UseMiddleware<RequestExceptionMiddleware>();
app.UseMiddleware<UsageMiddleware>();
app.UseMiddleware<RequestLogPerformanceMiddleware>();
return app;
@ -113,6 +114,7 @@ namespace Squidex.Config.Web
app.UseForwardedHeaders(GetForwardingOptions(config));
app.UseMiddleware<EnforceHttpsMiddleware>();
app.UseMiddleware<CleanupHostMiddleware>();
}

5
backend/src/Squidex/Config/Web/WebServices.cs

@ -11,6 +11,8 @@ using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Areas.Frontend.Middlewares;
using Squidex.Areas.OrleansDashboard.Middlewares;
using Squidex.Config.Domain;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure.Caching;
@ -46,6 +48,9 @@ namespace Squidex.Config.Web
services.AddSingletonAs<LocalCacheMiddleware>()
.AsSelf();
services.AddSingletonAs<UsageMiddleware>()
.AsSelf();
services.AddSingletonAs<RequestExceptionMiddleware>()
.AsSelf();

2
backend/src/Squidex/Pipeline/Squid/SquidMiddleware.cs

@ -26,7 +26,7 @@ namespace Squidex.Pipeline.Squid
this.next = next;
}
public async Task Invoke(HttpContext context)
public async Task InvokeAsync(HttpContext context)
{
var request = context.Request;

6
backend/src/Squidex/Startup.cs

@ -72,12 +72,12 @@ namespace Squidex
public void Configure(IApplicationBuilder app)
{
app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt();
app.UseSquidexForwardingRules(config);
app.UseSquidexTracking();
app.UseSquidexLocalCache();
app.UseSquidexCors();
app.UseSquidexForwardingRules(config);
app.UseSquidexHealthCheck();
app.UseSquidexRobotsTxt();
app.ConfigureApi();
app.ConfigurePortal();

131
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/ApiUsageTrackerTests.cs

@ -0,0 +1,131 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Xunit;
namespace Squidex.Infrastructure.UsageTracking
{
public class ApiUsageTrackerTests
{
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>();
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly ApiUsageTracker sut;
public ApiUsageTrackerTests()
{
sut = new ApiUsageTracker(usageTracker);
}
[Fact]
public async Task Should_track_usage()
{
Counters? measuredCounters = null;
A.CallTo(() => usageTracker.TrackAsync(date, $"{key}_API", null, A<Counters>.Ignored))
.Invokes(args =>
{
measuredCounters = args.GetArgument<Counters>(3)!;
});
await sut.TrackAsync(date, key, null, 4, 120, 1024);
measuredCounters.Should().BeEquivalentTo(new Counters
{
[ApiUsageTracker.CounterTotalBytes] = 1024,
[ApiUsageTracker.CounterTotalCalls] = 1,
[ApiUsageTracker.CounterTotalElapsedMs] = 120,
[ApiUsageTracker.CounterTotalWeight] = 4
});
}
[Fact]
public async Task Should_query_weight_from_tracker()
{
var counters = new Counters
{
[ApiUsageTracker.CounterTotalWeight] = 4
};
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date))
.Returns(counters);
var result = await sut.GetMonthlyWeightAsync(key, date);
Assert.Equal(4, result);
}
[Fact]
public async Task Should_query_stats_from_tracker()
{
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
var counters = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["my-category"] = new List<(DateTime Date, Counters Counters)>
{
(dateFrom.AddDays(0), Counters(0, 0, 0)),
(dateFrom.AddDays(1), Counters(4, 100, 2048)),
(dateFrom.AddDays(2), Counters(0, 0, 0)),
(dateFrom.AddDays(3), Counters(2, 60, 1024)),
(dateFrom.AddDays(4), Counters(3, 30, 512))
},
["*"] = new List<(DateTime Date, Counters Counters)>
{
(dateFrom.AddDays(0), Counters(1, 20, 128)),
(dateFrom.AddDays(1), Counters(0, 0, 0)),
(dateFrom.AddDays(2), Counters(5, 90, 16)),
(dateFrom.AddDays(3), Counters(0, 0, 0)),
(dateFrom.AddDays(4), Counters(0, 0, 0))
}
};
A.CallTo(() => usageTracker.QueryAsync($"{key}_API", dateFrom, dateTo))
.Returns(counters);
var (summary, stats) = await sut.QueryAsync(key, dateFrom, dateTo);
stats.Should().BeEquivalentTo(new Dictionary<string, List<(DateTime Date, ApiStats Counters)>>
{
["my-category"] = new List<(DateTime Date, ApiStats Counters)>
{
(dateFrom.AddDays(0), new ApiStats(0, 0, 0)),
(dateFrom.AddDays(1), new ApiStats(4, 25, 2048)),
(dateFrom.AddDays(2), new ApiStats(0, 0, 0)),
(dateFrom.AddDays(3), new ApiStats(2, 30, 1024)),
(dateFrom.AddDays(4), new ApiStats(3, 10, 512))
},
["*"] = new List<(DateTime Date, ApiStats)>
{
(dateFrom.AddDays(0), new ApiStats(1, 20, 128)),
(dateFrom.AddDays(1), new ApiStats(0, 0, 0)),
(dateFrom.AddDays(2), new ApiStats(5, 18, 16)),
(dateFrom.AddDays(3), new ApiStats(0, 0, 0)),
(dateFrom.AddDays(4), new ApiStats(0, 0, 0))
}
});
summary.Should().BeEquivalentTo(new ApiStats(15, 20, 3728));
}
private static Counters Counters(long calls, long elapsed, long bytes)
{
return new Counters
{
[ApiUsageTracker.CounterTotalBytes] = bytes,
[ApiUsageTracker.CounterTotalCalls] = calls,
[ApiUsageTracker.CounterTotalElapsedMs] = elapsed
};
}
}
}

201
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

@ -20,6 +20,7 @@ namespace Squidex.Infrastructure.UsageTracking
private readonly IUsageRepository usageStore = A.Fake<IUsageRepository>();
private readonly ISemanticLog log = A.Fake<ISemanticLog>();
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly BackgroundUsageTracker sut;
public BackgroundUsageTrackerTests()
@ -32,7 +33,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.TrackAsync(key, "category1", 1, 1000));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.TrackAsync(date, key, "category1", new Counters()));
}
[Fact]
@ -40,142 +41,138 @@ namespace Squidex.Infrastructure.UsageTracking
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.QueryAsync(key, DateTime.Today, DateTime.Today.AddDays(1)));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.QueryAsync(key, date, date.AddDays(1)));
}
[Fact]
public async Task Should_throw_exception_if_querying_montly_usage_on_disposed_object()
public async Task Should_throw_exception_if_querying_monthly_counters_on_disposed_object()
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetMonthlyCallsAsync(key, DateTime.Today));
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetForMonthAsync(key, date));
}
[Fact]
public async Task Should_throw_exception_if_querying_summary_counters_on_disposed_object()
{
sut.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(() => sut.GetAsync(key, date, date));
}
[Fact]
public async Task Should_sum_up_when_getting_monthly_calls()
{
var date = new DateTime(2016, 1, 15);
var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1);
IReadOnlyList<StoredUsage> originalData = new List<StoredUsage>
var originalData = new List<StoredUsage>
{
new StoredUsage("category1", date.AddDays(1), Counters(10, 15)),
new StoredUsage("category1", date.AddDays(3), Counters(13, 18)),
new StoredUsage("category1", date.AddDays(5), Counters(15, 20)),
new StoredUsage("category1", date.AddDays(7), Counters(17, 22))
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22))
};
A.CallTo(() => usageStore.QueryAsync($"{key}_API", new DateTime(2016, 1, 1), new DateTime(2016, 1, 15)))
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.GetMonthlyCallsAsync(key, date);
var result = await sut.GetForMonthAsync(key, date);
Assert.Equal(55, result);
Assert.Equal(38, result["A"]);
Assert.Equal(55, result["B"]);
}
[Fact]
public async Task Should_sum_up_when_getting_last_calls_calls()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(10);
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
IReadOnlyList<StoredUsage> originalData = new List<StoredUsage>
var originalData = new List<StoredUsage>
{
new StoredUsage("category1", f.AddDays(1), Counters(10, 15)),
new StoredUsage("category1", f.AddDays(3), Counters(13, 18)),
new StoredUsage("category1", f.AddDays(5), Counters(15, 20)),
new StoredUsage("category1", f.AddDays(7), Counters(17, 22))
new StoredUsage("category1", date.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("category1", date.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("category1", date.AddDays(5), Counters(a: 15)),
new StoredUsage("category1", date.AddDays(7), Counters(b: 22))
};
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.GetPreviousCallsAsync(key, f, t);
var result = await sut.GetAsync(key, dateFrom, dateTo);
Assert.Equal(55, result);
Assert.Equal(38, result["A"]);
Assert.Equal(55, result["B"]);
}
[Fact]
public async Task Should_fill_missing_days()
public async Task Should_create_empty_results_with_default_category_is_result_is_empty()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(4);
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
var originalData = new List<StoredUsage>
{
new StoredUsage("MyCategory1", f.AddDays(1), Counters(10, 15)),
new StoredUsage("MyCategory1", f.AddDays(3), Counters(13, 18)),
new StoredUsage("MyCategory1", f.AddDays(4), Counters(15, 20)),
new StoredUsage(null, f.AddDays(0), Counters(17, 22)),
new StoredUsage(null, f.AddDays(2), Counters(11, 14))
};
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
.Returns(originalData);
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(new List<StoredUsage>());
var result = await sut.QueryAsync(key, f, t);
var result = await sut.QueryAsync(key, dateFrom, dateTo);
var expected = new Dictionary<string, List<DateUsage>>
var expected = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["MyCategory1"] = new List<DateUsage>
{
new DateUsage(f.AddDays(0), 00, 00),
new DateUsage(f.AddDays(1), 10, 15),
new DateUsage(f.AddDays(2), 00, 00),
new DateUsage(f.AddDays(3), 13, 18),
new DateUsage(f.AddDays(4), 15, 20)
},
["*"] = new List<DateUsage>
["*"] = new List<(DateTime Date, Counters Counters)>
{
new DateUsage(f.AddDays(0), 17, 22),
new DateUsage(f.AddDays(1), 00, 00),
new DateUsage(f.AddDays(2), 11, 14),
new DateUsage(f.AddDays(3), 00, 00),
new DateUsage(f.AddDays(4), 00, 00)
(dateFrom.AddDays(0), new Counters()),
(dateFrom.AddDays(1), new Counters()),
(dateFrom.AddDays(2), new Counters()),
(dateFrom.AddDays(3), new Counters()),
(dateFrom.AddDays(4), new Counters()),
}
};
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Should_fill_missing_days_with_star()
public async Task Should_create_results_with_filled_days()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(4);
var dateFrom = date;
var dateTo = dateFrom.AddDays(4);
A.CallTo(() => usageStore.QueryAsync($"{key}_API", f, t))
.Returns(new List<StoredUsage>());
var originalData = new List<StoredUsage>
{
new StoredUsage("my-category", dateFrom.AddDays(1), Counters(a: 10, b: 15)),
new StoredUsage("my-category", dateFrom.AddDays(3), Counters(a: 13, b: 18)),
new StoredUsage("my-category", dateFrom.AddDays(4), Counters(a: 15, b: 20)),
new StoredUsage(null, dateFrom.AddDays(0), Counters(a: 17, b: 22)),
new StoredUsage(null, dateFrom.AddDays(2), Counters(a: 11, b: 14))
};
A.CallTo(() => usageStore.QueryAsync(key, dateFrom, dateTo))
.Returns(originalData);
var result = await sut.QueryAsync(key, f, t);
var result = await sut.QueryAsync(key, dateFrom, dateTo);
var expected = new Dictionary<string, List<DateUsage>>
var expected = new Dictionary<string, List<(DateTime Date, Counters Counters)>>
{
["*"] = new List<DateUsage>
["my-category"] = new List<(DateTime Date, Counters Counters)>
{
(dateFrom.AddDays(0), Counters()),
(dateFrom.AddDays(1), Counters(a: 10, b: 15)),
(dateFrom.AddDays(2), Counters()),
(dateFrom.AddDays(3), Counters(a: 13, b: 18)),
(dateFrom.AddDays(4), Counters(a: 15, b: 20))
},
["*"] = new List<(DateTime Date, Counters Counters)>
{
new DateUsage(f.AddDays(0), 00, 00),
new DateUsage(f.AddDays(1), 00, 00),
new DateUsage(f.AddDays(2), 00, 00),
new DateUsage(f.AddDays(3), 00, 00),
new DateUsage(f.AddDays(4), 00, 00)
(dateFrom.AddDays(0), Counters(a: 17, b: 22)),
(dateFrom.AddDays(1), Counters()),
(dateFrom.AddDays(2), Counters(a: 11, b: 14)),
(dateFrom.AddDays(3), Counters()),
(dateFrom.AddDays(4), Counters())
}
};
result.Should().BeEquivalentTo(expected);
}
[Fact]
public async Task Should_not_track_if_weight_less_than_zero()
{
await sut.TrackAsync(key, "MyCategory", -1, 1000);
await sut.TrackAsync(key, "MyCategory", 0, 1000);
sut.Next();
sut.Dispose();
A.CallTo(() => usageStore.TrackUsagesAsync(A<UsageUpdate[]>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_aggregate_and_store_on_dispose()
{
@ -183,18 +180,16 @@ namespace Squidex.Infrastructure.UsageTracking
var key2 = Guid.NewGuid().ToString();
var key3 = Guid.NewGuid().ToString();
var today = DateTime.Today;
await sut.TrackAsync(key1, "MyCategory1", 1, 1000);
await sut.TrackAsync(date, key1, "my-category", Counters(a: 1, b: 1000));
await sut.TrackAsync(key2, "MyCategory1", 1.0, 2000);
await sut.TrackAsync(key2, "MyCategory1", 0.5, 3000);
await sut.TrackAsync(date, key2, "my-category", Counters(a: 1.0, b: 2000));
await sut.TrackAsync(date, key2, "my-category", Counters(a: 0.5, b: 3000));
await sut.TrackAsync(key3, "MyCategory1", 0.3, 4000);
await sut.TrackAsync(key3, "MyCategory1", 0.1, 5000);
await sut.TrackAsync(date, key3, "my-category", Counters(a: 0.3, b: 4000));
await sut.TrackAsync(date, key3, "my-category", Counters(a: 0.1, b: 5000));
await sut.TrackAsync(key3, null, 0.5, 2000);
await sut.TrackAsync(key3, null, 0.5, 6000);
await sut.TrackAsync(date, key3, null, Counters(a: 0.5, b: 2000));
await sut.TrackAsync(date, key3, null, Counters(a: 0.5, b: 6000));
UsageUpdate[]? updates = null;
@ -206,23 +201,31 @@ namespace Squidex.Infrastructure.UsageTracking
updates.Should().BeEquivalentTo(new[]
{
new UsageUpdate(today, $"{key1}_API", "MyCategory1", Counters(1.0, 1000)),
new UsageUpdate(today, $"{key2}_API", "MyCategory1", Counters(1.5, 5000)),
new UsageUpdate(today, $"{key3}_API", "MyCategory1", Counters(0.4, 9000)),
new UsageUpdate(today, $"{key3}_API", "*", Counters(1, 8000))
new UsageUpdate(date, key1, "my-category", Counters(a: 1.0, b: 1000)),
new UsageUpdate(date, key2, "my-category", Counters(a: 1.5, b: 5000)),
new UsageUpdate(date, key3, "my-category", Counters(a: 0.4, b: 9000)),
new UsageUpdate(date, key3, "*", Counters(1, 8000))
}, o => o.ComparingByMembers<UsageUpdate>());
A.CallTo(() => usageStore.TrackUsagesAsync(A<UsageUpdate[]>._))
.MustHaveHappened();
}
private static Counters Counters(double count, long ms)
private static Counters Counters(double? a = null, double? b = null)
{
return new Counters
var result = new Counters();
if (a != null)
{
[BackgroundUsageTracker.CounterTotalCalls] = count,
[BackgroundUsageTracker.CounterTotalElapsedMs] = ms
};
result["A"] = a.Value;
}
if (b != null)
{
result["B"] = b.Value;
}
return result;
}
}
}

69
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/CachingUsageTrackerTests.cs

@ -18,6 +18,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
private readonly MemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly string key = Guid.NewGuid().ToString();
private readonly DateTime date = DateTime.Today;
private readonly IUsageTracker inner = A.Fake<IUsageTracker>();
private readonly IUsageTracker sut;
@ -29,54 +30,78 @@ namespace Squidex.Infrastructure.UsageTracking
[Fact]
public async Task Should_forward_track_call()
{
await sut.TrackAsync(key, "MyCategory", 123, 456);
var counters = new Counters();
A.CallTo(() => inner.TrackAsync(key, "MyCategory", 123, 456))
await sut.TrackAsync(date, key, "my-category", counters);
A.CallTo(() => inner.TrackAsync(date, key, "my-category", counters))
.MustHaveHappened();
}
[Fact]
public async Task Should_forward_query_call()
{
await sut.QueryAsync(key, DateTime.MaxValue, DateTime.MinValue);
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
await sut.QueryAsync(key, dateFrom, dateTo);
A.CallTo(() => inner.QueryAsync(key, DateTime.MaxValue, DateTime.MinValue))
A.CallTo(() => inner.QueryAsync(key, dateFrom, dateTo))
.MustHaveHappened();
}
[Fact]
public async Task Should_cache_monthly_usage()
{
A.CallTo(() => inner.GetMonthlyCallsAsync(key, DateTime.Today))
.Returns(100);
var counters = new Counters();
A.CallTo(() => inner.GetForMonthAsync(key, date))
.Returns(counters);
var result1 = await sut.GetMonthlyCallsAsync(key, DateTime.Today);
var result2 = await sut.GetMonthlyCallsAsync(key, DateTime.Today);
var result1 = await sut.GetForMonthAsync(key, date);
var result2 = await sut.GetForMonthAsync(key, date);
Assert.Equal(100, result1);
Assert.Equal(100, result2);
Assert.Same(counters, result1);
Assert.Same(counters, result2);
A.CallTo(() => inner.GetMonthlyCallsAsync(key, DateTime.Today))
.MustHaveHappened(1, Times.Exactly);
A.CallTo(() => inner.GetForMonthAsync(key, DateTime.Today))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_cache_days_usage()
{
var f = DateTime.Today;
var t = DateTime.Today.AddDays(10);
var counters = new Counters();
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo))
.Returns(counters);
var result1 = await sut.GetAsync(key, dateFrom, dateTo);
var result2 = await sut.GetAsync(key, dateFrom, dateTo);
A.CallTo(() => inner.GetPreviousCallsAsync(key, f, t))
.Returns(120);
Assert.Same(counters, result1);
Assert.Same(counters, result2);
A.CallTo(() => inner.GetAsync(key, dateFrom, dateTo))
.MustHaveHappenedOnceExactly();
}
[Fact]
public async Task Should_not_cache_queries()
{
var dateFrom = date;
var dateTo = dateFrom.AddDays(10);
var result1 = await sut.GetPreviousCallsAsync(key, f, t);
var result2 = await sut.GetPreviousCallsAsync(key, f, t);
var result1 = await sut.QueryAsync(key, dateFrom, dateTo);
var result2 = await sut.QueryAsync(key, dateFrom, dateTo);
Assert.Equal(120, result1);
Assert.Equal(120, result2);
Assert.NotSame(result2, result1);
A.CallTo(() => inner.GetPreviousCallsAsync(key, f, t))
.MustHaveHappened(1, Times.Exactly);
A.CallTo(() => inner.QueryAsync(key, dateFrom, dateTo))
.MustHaveHappenedTwiceOrMore();
}
}
}

1
backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithActorCommandMiddlewareTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;

78
backend/tests/Squidex.Web.Tests/Pipeline/ApiCostsFilterTests.cs

@ -13,9 +13,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure.UsageTracking;
@ -25,13 +23,10 @@ namespace Squidex.Web.Pipeline
{
public class ApiCostsFilterTests
{
private readonly IActionContextAccessor actionContextAccessor = A.Fake<IActionContextAccessor>();
private readonly IAppEntity appEntity = A.Fake<IAppEntity>();
private readonly IAppLimitsPlan appPlan = A.Fake<IAppLimitsPlan>();
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>();
private readonly IAppPlansProvider appPlansProvider = A.Fake<IAppPlansProvider>();
private readonly IClock clock = A.Fake<IClock>();
private readonly IUsageTracker usageTracker = A.Fake<IUsageTracker>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly ActionExecutingContext actionContext;
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly ActionExecutionDelegate next;
@ -48,9 +43,6 @@ namespace Squidex.Web.Pipeline
new ActionDescriptor()),
new List<IFilterMetadata>(), new Dictionary<string, object>(), null);
A.CallTo(() => actionContextAccessor.ActionContext)
.Returns(actionContext);
A.CallTo(() => appPlansProvider.GetPlan(null))
.Returns(appPlan);
@ -60,7 +52,7 @@ namespace Squidex.Web.Pipeline
A.CallTo(() => appPlan.BlockingApiCalls)
.ReturnsLazily(x => apiCallsBlocking);
A.CallTo(() => usageTracker.GetMonthlyCallsAsync(A<string>._, DateTime.Today))
A.CallTo(() => usageTracker.GetMonthlyWeightAsync(A<string>._, DateTime.Today))
.ReturnsLazily(x => Task.FromResult(apiCallsCurrent));
next = () =>
@ -70,7 +62,7 @@ namespace Squidex.Web.Pipeline
return Task.FromResult<ActionExecutedContext?>(null);
};
sut = new ApiCostsFilter(appLogStore, appPlansProvider, usageTracker, clock);
sut = new ApiCostsFilter(appPlansProvider, usageTracker);
}
[Fact]
@ -87,9 +79,6 @@ namespace Squidex.Web.Pipeline
Assert.Equal(429, (actionContext.Result as StatusCodeResult)?.StatusCode);
Assert.False(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
@ -105,9 +94,6 @@ namespace Squidex.Web.Pipeline
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, 13, A<double>._))
.MustHaveHappened();
}
[Fact]
@ -123,64 +109,6 @@ namespace Squidex.Web.Pipeline
await sut.OnActionExecutionAsync(actionContext, next);
Assert.False(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, 13, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_weight_is_zero()
{
sut.FilterDefinition = new ApiCostsAttribute(0);
SetupApp();
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_app_not_defined()
{
sut.FilterDefinition = new ApiCostsAttribute(1);
apiCallsCurrent = 1000;
apiCallsBlocking = 600;
await sut.OnActionExecutionAsync(actionContext, next);
Assert.True(isNextCalled);
A.CallTo(() => usageTracker.TrackAsync(A<string>._, A<string>._, A<double>._, A<double>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_log_request_event_if_weight_is_zero()
{
sut.FilterDefinition = new ApiCostsAttribute(0);
SetupApp();
httpContext.Request.Method = "GET";
httpContext.Request.Path = "/my-path";
var instant = SystemClock.Instance.GetCurrentInstant();
A.CallTo(() => clock.GetCurrentInstant())
.Returns(instant);
await sut.OnActionExecutionAsync(actionContext, next);
A.CallTo(() => appLogStore.LogAsync(appEntity.Id, instant, "GET", "/my-path", null, null, A<long>._, 0))
.MustHaveHappened();
}
private void SetupApp()

2
backend/tests/Squidex.Web.Tests/Pipeline/CleanupHostMiddlewareTests.cs

@ -38,7 +38,7 @@ namespace Squidex.Web.Pipeline
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("host", 443);
await sut.Invoke(httpContext);
await sut.InvokeAsync(httpContext);
Assert.Null(httpContext.Request.Host.Port);
Assert.True(isNextCalled);

163
backend/tests/Squidex.Web.Tests/Pipeline/UsageMiddlewareTests.cs

@ -0,0 +1,163 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.AspNetCore.Http;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking;
using Xunit;
namespace Squidex.Web.Pipeline
{
public class UsageMiddlewareTests
{
private readonly IAppLogStore appLogStore = A.Fake<IAppLogStore>();
private readonly IApiUsageTracker usageTracker = A.Fake<IApiUsageTracker>();
private readonly IClock clock = A.Fake<IClock>();
private readonly Instant instant = SystemClock.Instance.GetCurrentInstant();
private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly RequestDelegate next;
private readonly UsageMiddleware sut;
private bool isNextCalled;
public UsageMiddlewareTests()
{
A.CallTo(() => clock.GetCurrentInstant())
.Returns(instant);
next = x =>
{
isNextCalled = true;
return Task.CompletedTask;
};
sut = new UsageMiddleware(appLogStore, usageTracker, clock);
}
[Fact]
public async Task Should_not_track_if_app_not_defined()
{
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_track_if_call_blocked()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_track_if_calls_left()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, A<long>._))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_request_bytes()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
httpContext.Request.ContentLength = 1024;
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 1024))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_response_bytes()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(13));
await sut.InvokeAsync(httpContext, async x =>
{
await x.Response.WriteAsync("Hello World");
await next(x);
});
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, 13, A<long>._, 11))
.MustHaveHappened();
}
[Fact]
public async Task Should_not_track_if_weight_is_zero()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0));
await sut.InvokeAsync(httpContext, next);
Assert.True(isNextCalled);
var date = instant.ToDateTimeUtc().Date;
A.CallTo(() => usageTracker.TrackAsync(date, A<string>._, A<string>._, A<double>._, A<long>._, A<long>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_log_request_even_if_weight_is_zero()
{
httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
httpContext.Features.Set<IApiCostsFeature>(new ApiCostsAttribute(0));
httpContext.Request.Method = "GET";
httpContext.Request.Path = "/my-path";
await sut.InvokeAsync(httpContext, next);
A.CallTo(() => appLogStore.LogAsync(appId.Id, instant, "GET", "/my-path", null, null, A<long>._, 0))
.MustHaveHappened();
}
}
}

32
frontend/app/features/dashboard/pages/dashboard-page.component.html

@ -85,11 +85,11 @@
<div class="card card-lg">
<div class="card-header">
API Performance (ms)
API Performance (ms): {{callsPerformance}}ms avg
<div class="float-right">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isPerformanceStacked" />
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isStacked" />
<label class="form-check-label" for="stacked">
Stacked
</label>
@ -97,7 +97,7 @@
</div>
</div>
<div class="card-body">
<chart type="bar" [data]="chartCallsPerformance" [options]="isPerformanceStacked ? stackedChartOptions : chartOptions"></chart>
<chart type="bar" [data]="chartCallsPerformance" [options]="isStacked ? stackedChartOptions : chartOptions"></chart>
</div>
</div>
@ -107,7 +107,7 @@
<div class="aggregation" *ngIf="callsCurrent >= 0">
<div class="aggregation-label">This month</div>
<div class="aggregation-value">{{callsCurrent | sqxKNumber}}</div>
<div class="aggregation-label" *ngIf="callsMax > 0">Monthly limit: {{callsMax | sqxKNumber}}</div>
<div class="aggregation-label" *ngIf="callsAllowed > 0">Monthly limit: {{callsAllowed | sqxKNumber}}</div>
</div>
</div>
</div>
@ -122,10 +122,10 @@
<div class="card card">
<div class="card-header">Assets Size (MB)</div>
<div class="card-body">
<div class="aggregation" *ngIf="assetsCurrent >= 0">
<div class="aggregation" *ngIf="storageCurrent >= 0">
<div class="aggregation-label">Total Size</div>
<div class="aggregation-value">{{assetsCurrent | sqxFileSize}}</div>
<div class="aggregation-label" *ngIf="assetsMax > 0">Total limit: {{assetsMax | sqxFileSize}}</div>
<div class="aggregation-value">{{storageCurrent | sqxFileSize}}</div>
<div class="aggregation-label" *ngIf="storageAllowed > 0">Total limit: {{storageAllowed | sqxFileSize}}</div>
</div>
</div>
</div>
@ -137,6 +137,24 @@
</div>
</div>
<div class="card card-lg">
<div class="card-header">
Traffic (MB): {{callsBytes | sqxFileSize}} total
<div class="float-right">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="stacked" [(ngModel)]="isStacked" />
<label class="form-check-label" for="stacked">
Stacked
</label>
</div>
</div>
</div>
<div class="card-body">
<chart type="bar" [data]="chartCallsBytes" [options]="isStacked ? stackedChartOptions : chartOptions"></chart>
</div>
</div>
<div class="card card-lg">
<div class="card-header">History</div>
<div class="card-body card-history card-body-scroll">

82
frontend/app/features/dashboard/pages/dashboard-page.component.ts

@ -15,6 +15,7 @@ import {
fadeAnimation,
HistoryEventDto,
HistoryService,
LocalStoreService,
ResourceOwner,
UsagesService
} from '@app/shared';
@ -42,14 +43,7 @@ const COLORS: ReadonlyArray<string> = [
]
})
export class DashboardPageComponent extends ResourceOwner implements OnInit {
public profileDisplayName = '';
public chartStorageCount: any;
public chartStorageSize: any;
public chartCallsCount: any;
public chartCallsPerformance: any;
public isPerformanceStacked = false;
private isStackedValue: boolean;
public chartOptions = {
responsive: true,
@ -87,19 +81,42 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
public history: ReadonlyArray<HistoryEventDto> = [];
public assetsCurrent = 0;
public assetsMax = 0;
public profileDisplayName = '';
public chartStorageCount: any;
public chartStorageSize: any;
public chartCallsCount: any;
public chartCallsBytes: any;
public chartCallsPerformance: any;
public storageCurrent = 0;
public storageAllowed = 0;
public callsPerformance = 0;
public callsCurrent = 0;
public callsMax = 0;
public callsAllowed = 0;
public callsBytes = 0;
public get isStacked() {
return this.isStackedValue;
}
public set isStacked(value: boolean) {
this.localStore.setBoolean('dashboard.charts.stacked', value);
this.isStackedValue = value;
}
constructor(
public readonly appsState: AppsState,
public readonly authState: AuthService,
private readonly historyService: HistoryService,
private readonly usagesService: UsagesService
private readonly usagesService: UsagesService,
private readonly localStore: LocalStoreService
) {
super();
this.isStackedValue = localStore.getBoolean('dashboard.charts.stacked');
}
public ngOnInit() {
@ -107,16 +124,8 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getTodayStorage(app.name)))
.subscribe(dto => {
this.assetsCurrent = dto.size;
this.assetsMax = dto.maxAllowed;
}));
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getMonthCalls(app.name)))
.subscribe(dto => {
this.callsCurrent = dto.count;
this.callsMax = dto.maxAllowed;
this.storageCurrent = dto.size;
this.storageAllowed = dto.maxAllowed;
}));
this.own(
@ -166,32 +175,49 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit {
this.own(
this.appsState.selectedApp.pipe(
switchMap(app => this.usagesService.getCallsUsages(app.name, DateTime.today().addDays(-20), DateTime.today())))
.subscribe(dtos => {
const labels = createLabelsFromSet(dtos);
.subscribe(({ details, totalBytes, totalCalls, allowedCalls, averageMs }) => {
const labels = createLabelsFromSet(details);
this.chartCallsCount = {
labels,
datasets: Object.keys(dtos).map((k, i) => (
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: details[k].map(x => x.totalCalls)
}))
};
this.chartCallsBytes = {
labels,
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: dtos[k].map(x => x.count)
data: details[k].map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100)
}))
};
this.chartCallsPerformance = {
labels,
datasets: Object.keys(dtos).map((k, i) => (
datasets: Object.keys(details).map((k, i) => (
{
label: label(k),
backgroundColor: `rgba(${COLORS[i]}, 0.6)`,
borderColor: `rgba(${COLORS[i]}, 1)`,
borderWidth: 1,
data: dtos[k].map(x => x.averageMs)
data: details[k].map(x => x.averageMs)
}))
};
this.callsPerformance = averageMs;
this.callsBytes = totalBytes;
this.callsCurrent = totalCalls;
this.callsAllowed = allowedCalls;
}));
}

71
frontend/app/shared/services/usages.service.spec.ts

@ -10,8 +10,8 @@ import { inject, TestBed } from '@angular/core/testing';
import {
ApiUrlConfig,
CallsUsageDto,
CurrentCallsDto,
ApiUsageDto,
ApiUsagesDto,
CurrentStorageDto,
DateTime,
StorageUsageDto,
@ -38,7 +38,7 @@ describe('UsagesService', () => {
it('should make get request to get calls usages',
inject([UsagesService, HttpTestingController], (usagesService: UsagesService, httpMock: HttpTestingController) => {
let usages: { [category: string]: ReadonlyArray<CallsUsageDto> };
let usages: ApiUsagesDto;
usagesService.getCallsUsages('my-app', DateTime.parseISO_UTC('2017-10-12'), DateTime.parseISO_UTC('2017-10-13')).subscribe(result => {
usages = result;
@ -50,45 +50,36 @@ describe('UsagesService', () => {
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
category1: [
{
date: '2017-10-12',
count: 10,
averageMs: 130
},
{
date: '2017-10-13',
count: 13,
averageMs: 170
}
]
});
expect(usages!).toEqual({
category1: [
new CallsUsageDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130),
new CallsUsageDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170)
]
});
}));
it('should make get request to get month calls',
inject([UsagesService, HttpTestingController], (usagesService: UsagesService, httpMock: HttpTestingController) => {
let usages: CurrentCallsDto;
usagesService.getMonthCalls('my-app').subscribe(result => {
usages = result;
allowedCalls: 100,
totalBytes: 1024,
totalCalls: 40,
averageMs: 12.4,
details: {
category1: [
{
date: '2017-10-12',
totalBytes: 10,
totalCalls: 130,
averageMs: 12.3
},
{
date: '2017-10-13',
totalBytes: 13,
totalCalls: 170,
averageMs: 33.3
}
]
}
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/usages/calls/month');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ count: 130, maxAllowed: 150 });
expect(usages!).toEqual(new CurrentCallsDto(130, 150));
expect(usages!).toEqual(
new ApiUsagesDto(100, 1024, 40, 12.4, {
category1: [
new ApiUsageDto(DateTime.parseISO_UTC('2017-10-12'), 10, 130, 12.3),
new ApiUsageDto(DateTime.parseISO_UTC('2017-10-13'), 13, 170, 33.3)
]
})
);
}));
it('should make get request to get storage usages',

55
frontend/app/shared/services/usages.service.ts

@ -16,10 +16,22 @@ import {
pretifyError
} from '@app/framework';
export class CallsUsageDto {
export class ApiUsagesDto {
constructor(
public readonly allowedCalls: number,
public readonly totalBytes: number,
public readonly totalCalls: number,
public readonly averageMs: number,
public readonly details: { [category: string]: ReadonlyArray<ApiUsageDto> }
) {
}
}
export class ApiUsageDto {
constructor(
public readonly date: DateTime,
public readonly count: number,
public readonly totalBytes: number,
public readonly totalCalls: number,
public readonly averageMs: number
) {
}
@ -42,14 +54,6 @@ export class CurrentStorageDto {
}
}
export class CurrentCallsDto {
constructor(
public readonly count: number,
public readonly maxAllowed: number
) {
}
}
@Injectable()
export class UsagesService {
constructor(
@ -68,16 +72,6 @@ export class UsagesService {
pretifyError('Failed to load monthly api calls. Please reload.'));
}
public getMonthCalls(app: string): Observable<CurrentCallsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/month`);
return this.http.get<any>(url).pipe(
map(body => {
return new CurrentCallsDto(body.count, body.maxAllowed);
}),
pretifyError('Failed to load monthly api calls. Please reload.'));
}
public getTodayStorage(app: string): Observable<CurrentStorageDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/storage/today`);
@ -88,21 +82,30 @@ export class UsagesService {
pretifyError('Failed to load todays storage size. Please reload.'));
}
public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<{ [category: string]: ReadonlyArray<CallsUsageDto> }> {
public getCallsUsages(app: string, fromDate: DateTime, toDate: DateTime): Observable<ApiUsagesDto> {
const url = this.apiUrl.buildUrl(`api/apps/${app}/usages/calls/${fromDate.toUTCStringFormat('YYYY-MM-DD')}/${toDate.toUTCStringFormat('YYYY-MM-DD')}`);
return this.http.get<any>(url).pipe(
map(body => {
const usages: { [category: string]: CallsUsageDto[] } = {};
const details: { [category: string]: ApiUsageDto[] } = {};
for (let category of Object.keys(body)) {
usages[category] = body[category].map((item: any) =>
new CallsUsageDto(
for (let category of Object.keys(body.details)) {
details[category] = body.details[category].map((item: any) =>
new ApiUsageDto(
DateTime.parseISO_UTC(item.date),
item.count,
item.totalBytes,
item.totalCalls,
item.averageMs));
}
const usages =
new ApiUsagesDto(
body.allowedCalls,
body.totalBytes,
body.totalBytes,
body.averageMs,
details);
return usages;
}),
pretifyError('Failed to load calls usage. Please reload.'));

Loading…
Cancel
Save