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