diff --git a/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsage.cs b/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsage.cs new file mode 100644 index 000000000..7819cb569 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsage.cs @@ -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; } + } +} diff --git a/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsageStore.cs b/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsageStore.cs new file mode 100644 index 000000000..d910edf91 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsageStore.cs @@ -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, 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 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> 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(); + } + } +} diff --git a/src/Squidex.Infrastructure/Timers/CompletionTimer.cs b/src/Squidex.Infrastructure/Timers/CompletionTimer.cs index f334a6197..6d3a5c93c 100644 --- a/src/Squidex.Infrastructure/Timers/CompletionTimer.cs +++ b/src/Squidex.Infrastructure/Timers/CompletionTimer.cs @@ -21,12 +21,12 @@ namespace Squidex.Infrastructure.Timers private readonly Task runTask; private CancellationTokenSource delayToken; - public CompletionTimer(int delay, Func callback) + public CompletionTimer(int delayInMs, Func callback) { Guard.NotNull(callback, nameof(callback)); - Guard.GreaterThan(delay, 0, nameof(delay)); + Guard.GreaterThan(delayInMs, 0, nameof(delayInMs)); - runTask = RunInternal(delay, callback); + runTask = RunInternal(delayInMs, callback); } private async Task RunInternal(int delay, Func callback) diff --git a/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs new file mode 100644 index 000000000..adefee578 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs @@ -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 usages = new ConcurrentDictionary(); + + 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()); + + 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> 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(); + + 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; + } + } +} diff --git a/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs b/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs new file mode 100644 index 000000000..0195cd6c1 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs @@ -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> FindAsync(string key, DateTime fromDate, DateTime toDate); + } +} diff --git a/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs b/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs new file mode 100644 index 000000000..13015f7e1 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs @@ -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> FindAsync(string key, DateTime fromDate, DateTime toDate); + } +} diff --git a/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs b/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs new file mode 100644 index 000000000..5ba280748 --- /dev/null +++ b/src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs @@ -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; + } + } +} diff --git a/src/Squidex/Config/Domain/InfrastructureModule.cs b/src/Squidex/Config/Domain/InfrastructureModule.cs index 5a47fa619..5aafb4a51 100644 --- a/src/Squidex/Config/Domain/InfrastructureModule.cs +++ b/src/Squidex/Config/Domain/InfrastructureModule.cs @@ -24,6 +24,7 @@ using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.UsageTracking; using Squidex.Pipeline; // ReSharper disable UnusedAutoPropertyAccessor.Local @@ -92,6 +93,10 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .As() .SingleInstance(); diff --git a/src/Squidex/Config/Domain/StoreMongoDbModule.cs b/src/Squidex/Config/Domain/StoreMongoDbModule.cs index def1f996c..68c8c5964 100644 --- a/src/Squidex/Config/Domain/StoreMongoDbModule.cs +++ b/src/Squidex/Config/Domain/StoreMongoDbModule.cs @@ -17,6 +17,8 @@ using Squidex.Core.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.CQRS.Events; using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.MongoDb.UsageTracker; +using Squidex.Infrastructure.UsageTracking; using Squidex.Read.Apps.Repositories; using Squidex.Read.Apps.Services.Implementations; using Squidex.Read.Assets.Repositories; @@ -113,6 +115,12 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); + builder.RegisterType() + .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) + .As() + .AsSelf() + .SingleInstance(); + builder.RegisterType() .WithParameter(ResolvedParameter.ForNamed(MongoDatabaseRegistration)) .As() diff --git a/src/Squidex/Config/Web/WebDependencies.cs b/src/Squidex/Config/Web/WebDependencies.cs index ac0a90cf2..8ebe98bcd 100644 --- a/src/Squidex/Config/Web/WebDependencies.cs +++ b/src/Squidex/Config/Web/WebDependencies.cs @@ -19,6 +19,7 @@ namespace Squidex.Config.Web services.AddMvc(options => { options.Filters.Add(typeof(LogPerformanceAttribute)); + options.Filters.Add(typeof(AppTrackingFilter)); }).AddMySerializers(); } } diff --git a/src/Squidex/Controllers/Api/Apps/AppUsageController.cs b/src/Squidex/Controllers/Api/Apps/AppUsageController.cs new file mode 100644 index 000000000..2da1751e9 --- /dev/null +++ b/src/Squidex/Controllers/Api/Apps/AppUsageController.cs @@ -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 +{ + /// + /// Retrieves usage information for apps. + /// + [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; + } + + /// + /// Get app usages. + /// + /// The name of the app. + /// The from date. + /// The to date. + /// + /// 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. + /// + [Authorize(Roles = SquidexRoles.AppEditor)] + [HttpGet] + [Route("apps/{app}/usages/{fromDate}/{toDate}")] + [ProducesResponseType(typeof(UsageDto[]), 200)] + public async Task 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); + } + } +} diff --git a/src/Squidex/Controllers/Api/Apps/Models/UsageDto.cs b/src/Squidex/Controllers/Api/Apps/Models/UsageDto.cs new file mode 100644 index 000000000..8e04538b5 --- /dev/null +++ b/src/Squidex/Controllers/Api/Apps/Models/UsageDto.cs @@ -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 + { + /// + /// The date when the usage was tracked. + /// + public DateTime Date { get; set; } + + /// + /// The number of calls. + /// + public long Count { get; set; } + + /// + /// The average duration in milliseconds. + /// + public long AverageMs { get; set; } + } +} diff --git a/src/Squidex/Controllers/ContentApi/PingController.cs b/src/Squidex/Controllers/Api/Ping/PingController.cs similarity index 61% rename from src/Squidex/Controllers/ContentApi/PingController.cs rename to src/Squidex/Controllers/Api/Ping/PingController.cs index 7f2db9b0e..f7b97c752 100644 --- a/src/Squidex/Controllers/ContentApi/PingController.cs +++ b/src/Squidex/Controllers/Api/Ping/PingController.cs @@ -8,21 +8,35 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; using Squidex.Core.Identity; using Squidex.Pipeline; -namespace Squidex.Controllers.ContentApi +namespace Squidex.Controllers.Api.Ping { + /// + /// Makes a ping request. + /// [Authorize(Roles = SquidexRoles.AppEditor)] [ApiExceptionFilter] [ServiceFilter(typeof(AppFilterAttribute))] + [SwaggerTag("Ping")] public class PingController : Controller { + /// + /// Get ping status. + /// + /// + /// 204 => Service ping successful. + /// + /// + /// Can be used to test, if the Squidex API is alive and responding. + /// [HttpGet] [Route("ping/{app}/")] public IActionResult GetPing() { - return Ok(); + return NoContent(); } } } diff --git a/src/Squidex/Pipeline/AppTrackingFilter.cs b/src/Squidex/Pipeline/AppTrackingFilter.cs new file mode 100644 index 000000000..4f906eace --- /dev/null +++ b/src/Squidex/Pipeline/AppTrackingFilter.cs @@ -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(); + + if (appFeature?.App != null) + { + context.HttpContext.Items["AppWatch"] = Stopwatch.StartNew(); + } + } + + public override void OnActionExecuted(ActionExecutedContext context) + { + var appFeature = context.HttpContext.Features.Get(); + + if (appFeature?.App != null) + { + var stopWatch = (Stopwatch)context.HttpContext.Items["AppWatch"]; + + stopWatch.Stop(); + + usageTracker.TrackAsync(appFeature.App.Id.ToString(), stopWatch.ElapsedMilliseconds); + } + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index 325a41cf6..26f183ffa 100644 --- a/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs b/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs new file mode 100644 index 000000000..ef679af3b --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs @@ -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 usageStore = new Mock(); + 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(() => sut.TrackAsync("key1", 1000)); + } + + [Fact] + public Task Should_throw_if_querying_on_disposed_object() + { + sut.Dispose(); + + return Assert.ThrowsAsync(() => 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 originalDate = new List + { + 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 + { + 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(); + } + } +}