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