mirror of https://github.com/Squidex/squidex.git
Browse Source
* Measure traffic. * Naming fixed. * Namings. * Formatting * Updates * Unify costs and weight * Background tracker. * Renamings. * Add AppId to response header.pull/493/head
committed by
GitHub
76 changed files with 1503 additions and 672 deletions
@ -0,0 +1,26 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Infrastructure.UsageTracking |
|||
{ |
|||
public sealed class ApiStatsSummary |
|||
{ |
|||
public long TotalCalls { get; } |
|||
|
|||
public long TotalBytes { get; } |
|||
|
|||
public double AverageElapsedMs { get; } |
|||
|
|||
public ApiStatsSummary(long totalCalls, double averageElapsedMs, long totalBytes) |
|||
{ |
|||
TotalCalls = totalCalls; |
|||
TotalBytes = totalBytes; |
|||
|
|||
AverageElapsedMs = averageElapsedMs; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
// ==========================================================================
|
|||
// 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 CounterTotalCalls = "TotalCalls"; |
|||
public const string CounterTotalElapsedMs = "TotalElapsedMs"; |
|||
private readonly IUsageTracker usageTracker; |
|||
|
|||
public ApiUsageTracker(IUsageTracker usageTracker) |
|||
{ |
|||
this.usageTracker = usageTracker; |
|||
} |
|||
|
|||
public async Task<long> GetMonthCostsAsync(string key, DateTime date) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var counters = await usageTracker.GetForMonthAsync(apiKey, date); |
|||
|
|||
return counters.GetInt64(CounterTotalCalls); |
|||
} |
|||
|
|||
public Task TrackAsync(DateTime date, string key, string? category, double weight, long elapsedMs, long bytes) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var counters = new Counters |
|||
{ |
|||
[CounterTotalCalls] = weight, |
|||
[CounterTotalElapsedMs] = elapsedMs, |
|||
[CounterTotalBytes] = bytes |
|||
}; |
|||
|
|||
return usageTracker.TrackAsync(date, apiKey, category, counters); |
|||
} |
|||
|
|||
public async Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate) |
|||
{ |
|||
var apiKey = GetKey(key); |
|||
|
|||
var queries = await usageTracker.QueryAsync(apiKey, fromDate, toDate); |
|||
|
|||
var details = new Dictionary<string, List<ApiStats>>(); |
|||
|
|||
var summaryBytes = 0L; |
|||
var summaryCalls = 0L; |
|||
var summaryElapsed = 0L; |
|||
|
|||
foreach (var (category, usages) in queries) |
|||
{ |
|||
var resultByCategory = new List<ApiStats>(); |
|||
|
|||
foreach (var (date, counters) in usages) |
|||
{ |
|||
var dateBytes = counters.GetInt64(CounterTotalBytes); |
|||
var dateCalls = counters.GetInt64(CounterTotalCalls); |
|||
var dateElapsed = counters.GetInt64(CounterTotalElapsedMs); |
|||
var dateElapsedAvg = CalculateAverage(dateCalls, dateElapsed); |
|||
|
|||
resultByCategory.Add(new ApiStats(date, dateCalls, dateElapsedAvg, dateBytes)); |
|||
|
|||
summaryBytes += dateBytes; |
|||
summaryCalls += dateCalls; |
|||
summaryElapsed += dateElapsed; |
|||
} |
|||
|
|||
details[category] = resultByCategory; |
|||
} |
|||
|
|||
var summaryElapsedAvg = CalculateAverage(summaryCalls, summaryElapsed); |
|||
|
|||
var summary = new ApiStatsSummary(summaryCalls, summaryElapsedAvg, summaryBytes); |
|||
|
|||
return (summary, details); |
|||
} |
|||
|
|||
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"; |
|||
} |
|||
} |
|||
} |
|||
@ -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 elapsedMs, long bytes); |
|||
|
|||
Task<long> GetMonthCostsAsync(string key, DateTime date); |
|||
|
|||
Task<(ApiStatsSummary, Dictionary<string, List<ApiStats>> Details)> QueryAsync(string key, DateTime fromDate, DateTime toDate); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
// ==========================================================================
|
|||
// 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 appId = context.Features.Get<IAppFeature>()?.AppId; |
|||
|
|||
var costs = context.Features.Get<IApiCostsFeature>()?.Costs ?? 0; |
|||
|
|||
if (appId != null) |
|||
{ |
|||
var elapsedMs = watch.Stop(); |
|||
|
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
var userId = context.User.OpenIdSubject(); |
|||
var userClient = context.User.OpenIdClientId(); |
|||
|
|||
await log.LogAsync(appId.Id, now, |
|||
context.Request.Method, |
|||
context.Request.Path, |
|||
userId, |
|||
userClient, |
|||
elapsedMs, |
|||
costs); |
|||
|
|||
if (costs > 0) |
|||
{ |
|||
var bytes = usageBody.BytesWritten; |
|||
|
|||
if (context.Request.ContentLength != null) |
|||
{ |
|||
bytes += context.Request.ContentLength.Value; |
|||
} |
|||
|
|||
var date = now.ToDateTimeUtc().Date; |
|||
|
|||
await usageTracker.TrackAsync(date, appId.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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
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 CallsUsageDtoDto |
|||
{ |
|||
/// <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 AverageElapsedMs { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The statistics by date and group.
|
|||
/// </summary>
|
|||
[Required] |
|||
public Dictionary<string, CallsUsagePerDateDto[]> Details { get; set; } |
|||
|
|||
public static CallsUsageDtoDto FromStats(long allowedCalls, ApiStatsSummary summary, Dictionary<string, List<ApiStats>> details) |
|||
{ |
|||
return new CallsUsageDtoDto |
|||
{ |
|||
AllowedCalls = allowedCalls, |
|||
AverageElapsedMs = summary.AverageElapsedMs, |
|||
TotalBytes = summary.TotalBytes, |
|||
TotalCalls = summary.TotalCalls, |
|||
Details = details.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsagePerDateDto.FromStats).ToArray()) |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
@ -1,22 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Statistics.Models |
|||
{ |
|||
public sealed class CurrentCallsDto |
|||
{ |
|||
/// <summary>
|
|||
/// The number of calls.
|
|||
/// </summary>
|
|||
public long Count { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The number of maximum allowed calls.
|
|||
/// </summary>
|
|||
public long MaxAllowed { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,130 @@ |
|||
// ==========================================================================
|
|||
// 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] = 4, |
|||
[ApiUsageTracker.CounterTotalElapsedMs] = 120 |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_query_from_tracker() |
|||
{ |
|||
var counters = new Counters |
|||
{ |
|||
[ApiUsageTracker.CounterTotalCalls] = 4 |
|||
}; |
|||
|
|||
A.CallTo(() => usageTracker.GetForMonthAsync($"{key}_API", date)) |
|||
.Returns(counters); |
|||
|
|||
var result = await sut.GetMonthCostsAsync(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<ApiStats>> |
|||
{ |
|||
["my-category"] = new List<ApiStats> |
|||
{ |
|||
new ApiStats(dateFrom.AddDays(0), 0, 0, 0), |
|||
new ApiStats(dateFrom.AddDays(1), 4, 25, 2048), |
|||
new ApiStats(dateFrom.AddDays(2), 0, 0, 0), |
|||
new ApiStats(dateFrom.AddDays(3), 2, 30, 1024), |
|||
new ApiStats(dateFrom.AddDays(4), 3, 10, 512) |
|||
}, |
|||
["*"] = new List<ApiStats> |
|||
{ |
|||
new ApiStats(dateFrom.AddDays(0), 1, 20, 128), |
|||
new ApiStats(dateFrom.AddDays(1), 0, 0, 0), |
|||
new ApiStats(dateFrom.AddDays(2), 5, 18, 16), |
|||
new ApiStats(dateFrom.AddDays(3), 0, 0, 0), |
|||
new ApiStats(dateFrom.AddDays(4), 0, 0, 0) |
|||
} |
|||
}); |
|||
|
|||
summary.Should().BeEquivalentTo(new ApiStatsSummary(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 |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
@ -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_costs_are_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_costs_are_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(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue