mirror of https://github.com/Squidex/squidex.git
16 changed files with 560 additions and 5 deletions
@ -0,0 +1,35 @@ |
|||||
|
// ==========================================================================
|
||||
|
// MongoUsage.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using MongoDB.Bson; |
||||
|
using MongoDB.Bson.Serialization.Attributes; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.MongoDb.UsageTracker |
||||
|
{ |
||||
|
public sealed class MongoUsage |
||||
|
{ |
||||
|
[BsonId] |
||||
|
[BsonElement] |
||||
|
[BsonRepresentation(BsonType.String)] |
||||
|
public string Key { get; set; } |
||||
|
|
||||
|
[BsonRequired] |
||||
|
[BsonElement] |
||||
|
[BsonDateTimeOptions(DateOnly = true)] |
||||
|
public DateTime Date { get; set; } |
||||
|
|
||||
|
[BsonRequired] |
||||
|
[BsonElement] |
||||
|
public long TotalCount { get; set; } |
||||
|
|
||||
|
[BsonRequired] |
||||
|
[BsonElement] |
||||
|
public long TotalElapsedMs { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
// ==========================================================================
|
||||
|
// MongoUsageStore.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using MongoDB.Driver; |
||||
|
using Squidex.Infrastructure.UsageTracking; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.MongoDb.UsageTracker |
||||
|
{ |
||||
|
public sealed class MongoUsageStore : MongoRepositoryBase<MongoUsage>, IUsageStore |
||||
|
{ |
||||
|
private static readonly UpdateOptions Upsert = new UpdateOptions { IsUpsert = true }; |
||||
|
|
||||
|
public MongoUsageStore(IMongoDatabase database) |
||||
|
: base(database) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
protected override string CollectionName() |
||||
|
{ |
||||
|
return "Usages"; |
||||
|
} |
||||
|
|
||||
|
protected override Task SetupCollectionAsync(IMongoCollection<MongoUsage> collection) |
||||
|
{ |
||||
|
return collection.Indexes.CreateOneAsync(IndexKeys.Ascending(x => x.Key).Ascending(x => x.Date)); |
||||
|
} |
||||
|
|
||||
|
public Task TrackUsagesAsync(DateTime date, string key, long count, long elapsedMs) |
||||
|
{ |
||||
|
return Collection.UpdateOneAsync(x => x.Key == key && x.Date == date, |
||||
|
Update |
||||
|
.Inc(x => x.TotalCount, count) |
||||
|
.Inc(x => x.TotalElapsedMs, elapsedMs) |
||||
|
.SetOnInsert(x => x.Key, key) |
||||
|
.SetOnInsert(x => x.Date, date), |
||||
|
Upsert); |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate) |
||||
|
{ |
||||
|
var entities = await Collection.Find(x => x.Key == key && x.Date >= fromDate && x.Date <= toDate).ToListAsync(); |
||||
|
|
||||
|
return entities.Select(x => new StoredUsage(x.Date, x.TotalCount, x.TotalElapsedMs)).ToList(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,111 @@ |
|||||
|
// ==========================================================================
|
||||
|
// BackgroundUsageTracker.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
using Squidex.Infrastructure.Timers; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.UsageTracking |
||||
|
{ |
||||
|
public sealed class BackgroundUsageTracker : DisposableObjectBase, IUsageTracker |
||||
|
{ |
||||
|
private readonly IUsageStore usageStore; |
||||
|
private readonly CompletionTimer timer; |
||||
|
private ConcurrentDictionary<string, Usage> usages = new ConcurrentDictionary<string, Usage>(); |
||||
|
|
||||
|
public sealed class Usage |
||||
|
{ |
||||
|
public readonly long Count; |
||||
|
public readonly long ElapsedMs; |
||||
|
|
||||
|
public Usage(long elapsed, long count = 1) |
||||
|
{ |
||||
|
ElapsedMs = elapsed; |
||||
|
|
||||
|
Count = count; |
||||
|
} |
||||
|
|
||||
|
public Usage Add(long elapsed) |
||||
|
{ |
||||
|
return new Usage(ElapsedMs + elapsed, Count + 1); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public BackgroundUsageTracker(IUsageStore usageStore) |
||||
|
{ |
||||
|
Guard.NotNull(usageStore, nameof(usageStore)); |
||||
|
|
||||
|
this.usageStore = usageStore; |
||||
|
|
||||
|
timer = new CompletionTimer(60 * 1000, ct => TrackAsync()); |
||||
|
} |
||||
|
|
||||
|
protected override void DisposeObject(bool disposing) |
||||
|
{ |
||||
|
if (disposing) |
||||
|
{ |
||||
|
timer.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Next() |
||||
|
{ |
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
timer.Trigger(); |
||||
|
} |
||||
|
|
||||
|
private Task TrackAsync() |
||||
|
{ |
||||
|
var today = DateTime.Today; |
||||
|
|
||||
|
var localUsages = Interlocked.Exchange(ref usages, new ConcurrentDictionary<string, Usage>()); |
||||
|
|
||||
|
return Task.WhenAll(localUsages.Select(x => |
||||
|
usageStore.TrackUsagesAsync( |
||||
|
today, |
||||
|
x.Key, |
||||
|
x.Value.Count, |
||||
|
x.Value.ElapsedMs))); |
||||
|
} |
||||
|
|
||||
|
public Task TrackAsync(string key, long elapsedMs) |
||||
|
{ |
||||
|
Guard.NotNull(key, nameof(key)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
usages.AddOrUpdate(key, _ => new Usage(elapsedMs), (k, x) => x.Add(elapsedMs)); |
||||
|
|
||||
|
return TaskHelper.Done; |
||||
|
} |
||||
|
|
||||
|
public async Task<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate) |
||||
|
{ |
||||
|
Guard.NotNull(key, nameof(key)); |
||||
|
|
||||
|
ThrowIfDisposed(); |
||||
|
|
||||
|
var originalUsages = await usageStore.FindAsync(key, fromDate, toDate); |
||||
|
|
||||
|
var result = new List<StoredUsage>(); |
||||
|
|
||||
|
for (var date = fromDate; date <= toDate; date = date.AddDays(1)) |
||||
|
{ |
||||
|
result.Add(originalUsages.FirstOrDefault(x => x.Date == date) ?? new StoredUsage(date, 0, 0)); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
// ==========================================================================
|
||||
|
// IUsageStore.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.UsageTracking |
||||
|
{ |
||||
|
public interface IUsageStore |
||||
|
{ |
||||
|
Task TrackUsagesAsync(DateTime date, string key, long count, long elapsedMs); |
||||
|
|
||||
|
Task<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
// ==========================================================================
|
||||
|
// IUsageTracker.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.UsageTracking |
||||
|
{ |
||||
|
public interface IUsageTracker |
||||
|
{ |
||||
|
Task TrackAsync(string key, long elapsedMs); |
||||
|
|
||||
|
Task<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
// ==========================================================================
|
||||
|
// StoredUsage.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.UsageTracking |
||||
|
{ |
||||
|
public sealed class StoredUsage |
||||
|
{ |
||||
|
public DateTime Date { get; } |
||||
|
|
||||
|
public long TotalCount { get; } |
||||
|
|
||||
|
public long TotalElapsedMs { get; } |
||||
|
|
||||
|
public StoredUsage(DateTime date, long totalCount, long totalElapsedMs) |
||||
|
{ |
||||
|
Date = date; |
||||
|
|
||||
|
TotalCount = totalCount; |
||||
|
TotalElapsedMs = totalElapsedMs; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
// ==========================================================================
|
||||
|
// AppUsageController.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.AspNetCore.Authorization; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using NSwag.Annotations; |
||||
|
using Squidex.Controllers.Api.Apps.Models; |
||||
|
using Squidex.Core.Identity; |
||||
|
using Squidex.Infrastructure.CQRS.Commands; |
||||
|
using Squidex.Infrastructure.UsageTracking; |
||||
|
using Squidex.Pipeline; |
||||
|
|
||||
|
namespace Squidex.Controllers.Api.Apps |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Retrieves usage information for apps.
|
||||
|
/// </summary>
|
||||
|
[ApiExceptionFilter] |
||||
|
[ServiceFilter(typeof(AppFilterAttribute))] |
||||
|
[SwaggerTag("Apps")] |
||||
|
public class AppUsageController : ControllerBase |
||||
|
{ |
||||
|
private readonly IUsageTracker usageTracker; |
||||
|
|
||||
|
public AppUsageController(ICommandBus commandBus, IUsageTracker usageTracker) |
||||
|
: base(commandBus) |
||||
|
{ |
||||
|
this.usageTracker = usageTracker; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Get app usages.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <param name="fromDate">The from date.</param>
|
||||
|
/// <param name="toDate">The to date.</param>
|
||||
|
/// <returns>
|
||||
|
/// 200 => Usage tracking results returned.
|
||||
|
/// 404 => App not found.
|
||||
|
/// 400 => Range between from date and to date is not valid or has more than 100 days.
|
||||
|
/// </returns>
|
||||
|
[Authorize(Roles = SquidexRoles.AppEditor)] |
||||
|
[HttpGet] |
||||
|
[Route("apps/{app}/usages/{fromDate}/{toDate}")] |
||||
|
[ProducesResponseType(typeof(UsageDto[]), 200)] |
||||
|
public async Task<IActionResult> GetUsages(string app, DateTime fromDate, DateTime toDate) |
||||
|
{ |
||||
|
if (fromDate > toDate && (toDate - fromDate).TotalDays > 100) |
||||
|
{ |
||||
|
return BadRequest(); |
||||
|
} |
||||
|
|
||||
|
var entities = await usageTracker.FindAsync(App.Id.ToString(), fromDate, toDate); |
||||
|
|
||||
|
var models = entities.Select(x => |
||||
|
{ |
||||
|
var averageMs = x.TotalElapsedMs / x.TotalCount; |
||||
|
|
||||
|
return new UsageDto { Date = x.Date, Count = x.TotalCount, AverageMs = averageMs }; |
||||
|
}).ToList(); |
||||
|
|
||||
|
return Ok(models); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
// ==========================================================================
|
||||
|
// UsageDto.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
|
||||
|
namespace Squidex.Controllers.Api.Apps.Models |
||||
|
{ |
||||
|
public class UsageDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The date when the usage was tracked.
|
||||
|
/// </summary>
|
||||
|
public DateTime Date { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The number of calls.
|
||||
|
/// </summary>
|
||||
|
public long Count { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The average duration in milliseconds.
|
||||
|
/// </summary>
|
||||
|
public long AverageMs { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
// ==========================================================================
|
||||
|
// AppTrackingFilter.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Diagnostics; |
||||
|
using Microsoft.AspNetCore.Mvc.Filters; |
||||
|
using Squidex.Infrastructure.UsageTracking; |
||||
|
|
||||
|
// ReSharper disable InvertIf
|
||||
|
|
||||
|
namespace Squidex.Pipeline |
||||
|
{ |
||||
|
public sealed class AppTrackingFilter : ActionFilterAttribute |
||||
|
{ |
||||
|
private readonly IUsageTracker usageTracker; |
||||
|
|
||||
|
public AppTrackingFilter(IUsageTracker usageTracker) |
||||
|
{ |
||||
|
this.usageTracker = usageTracker; |
||||
|
} |
||||
|
|
||||
|
public override void OnActionExecuting(ActionExecutingContext context) |
||||
|
{ |
||||
|
var appFeature = context.HttpContext.Features.Get<IAppFeature>(); |
||||
|
|
||||
|
if (appFeature?.App != null) |
||||
|
{ |
||||
|
context.HttpContext.Items["AppWatch"] = Stopwatch.StartNew(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public override void OnActionExecuted(ActionExecutedContext context) |
||||
|
{ |
||||
|
var appFeature = context.HttpContext.Features.Get<IAppFeature>(); |
||||
|
|
||||
|
if (appFeature?.App != null) |
||||
|
{ |
||||
|
var stopWatch = (Stopwatch)context.HttpContext.Items["AppWatch"]; |
||||
|
|
||||
|
stopWatch.Stop(); |
||||
|
|
||||
|
usageTracker.TrackAsync(appFeature.App.Id.ToString(), stopWatch.ElapsedMilliseconds); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
// ==========================================================================
|
||||
|
// BackgroundUsageTrackerTests.cs
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex Group
|
||||
|
// All rights reserved.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using FluentAssertions; |
||||
|
using Moq; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Infrastructure.UsageTracking |
||||
|
{ |
||||
|
public class BackgroundUsageTrackerTests |
||||
|
{ |
||||
|
private readonly Mock<IUsageStore> usageStore = new Mock<IUsageStore>(); |
||||
|
private readonly BackgroundUsageTracker sut; |
||||
|
|
||||
|
public BackgroundUsageTrackerTests() |
||||
|
{ |
||||
|
sut = new BackgroundUsageTracker(usageStore.Object); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public Task Should_throw_if_tracking_on_disposed_object() |
||||
|
{ |
||||
|
sut.Dispose(); |
||||
|
|
||||
|
return Assert.ThrowsAsync<ObjectDisposedException>(() => sut.TrackAsync("key1", 1000)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public Task Should_throw_if_querying_on_disposed_object() |
||||
|
{ |
||||
|
sut.Dispose(); |
||||
|
|
||||
|
return Assert.ThrowsAsync<ObjectDisposedException>(() => sut.FindAsync("key1", DateTime.Today, DateTime.Today.AddDays(1))); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_fill_missing_days() |
||||
|
{ |
||||
|
var dateFrom = DateTime.Today; |
||||
|
var dateTo = DateTime.Today.AddDays(7); |
||||
|
|
||||
|
IReadOnlyList<StoredUsage> originalDate = new List<StoredUsage> |
||||
|
{ |
||||
|
new StoredUsage(dateFrom.AddDays(1), 10, 15), |
||||
|
new StoredUsage(dateFrom.AddDays(3), 13, 18), |
||||
|
new StoredUsage(dateFrom.AddDays(5), 15, 20), |
||||
|
new StoredUsage(dateFrom.AddDays(7), 17, 22), |
||||
|
}; |
||||
|
|
||||
|
usageStore.Setup(x => x.FindAsync("key", dateFrom, dateTo)).Returns(Task.FromResult(originalDate)); |
||||
|
|
||||
|
var result = await sut.FindAsync("key", dateFrom, dateTo); |
||||
|
|
||||
|
result.ShouldBeEquivalentTo(new List<StoredUsage> |
||||
|
{ |
||||
|
new StoredUsage(dateFrom.AddDays(0), 00, 00), |
||||
|
new StoredUsage(dateFrom.AddDays(1), 10, 15), |
||||
|
new StoredUsage(dateFrom.AddDays(2), 00, 00), |
||||
|
new StoredUsage(dateFrom.AddDays(3), 13, 18), |
||||
|
new StoredUsage(dateFrom.AddDays(4), 00, 00), |
||||
|
new StoredUsage(dateFrom.AddDays(5), 15, 20), |
||||
|
new StoredUsage(dateFrom.AddDays(6), 00, 00), |
||||
|
new StoredUsage(dateFrom.AddDays(7), 17, 22), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_aggregate_and_store_on_dispose() |
||||
|
{ |
||||
|
var today = DateTime.Today; |
||||
|
|
||||
|
usageStore.Setup(x => x.TrackUsagesAsync(today, "key1", 1, 1000)).Returns(TaskHelper.Done).Verifiable(); |
||||
|
usageStore.Setup(x => x.TrackUsagesAsync(today, "key2", 2, 5000)).Returns(TaskHelper.Done).Verifiable(); |
||||
|
usageStore.Setup(x => x.TrackUsagesAsync(today, "key3", 3, 15000)).Returns(TaskHelper.Done).Verifiable(); |
||||
|
|
||||
|
await sut.TrackAsync("key1", 1000); |
||||
|
|
||||
|
await sut.TrackAsync("key2", 2000); |
||||
|
await sut.TrackAsync("key2", 3000); |
||||
|
|
||||
|
await sut.TrackAsync("key3", 4000); |
||||
|
await sut.TrackAsync("key3", 5000); |
||||
|
await sut.TrackAsync("key3", 6000); |
||||
|
|
||||
|
sut.Next(); |
||||
|
|
||||
|
await Task.Delay(100); |
||||
|
|
||||
|
usageStore.VerifyAll(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue