Browse Source

Feature tracking API.

pull/65/head
Sebastian Stehle 10 years ago
parent
commit
3c0d0bc60c
  1. 35
      src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsage.cs
  2. 55
      src/Squidex.Infrastructure.MongoDb/UsageTracker/MongoUsageStore.cs
  3. 6
      src/Squidex.Infrastructure/Timers/CompletionTimer.cs
  4. 111
      src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  5. 21
      src/Squidex.Infrastructure/UsageTracking/IUsageStore.cs
  6. 21
      src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  7. 29
      src/Squidex.Infrastructure/UsageTracking/StoredUsage.cs
  8. 5
      src/Squidex/Config/Domain/InfrastructureModule.cs
  9. 8
      src/Squidex/Config/Domain/StoreMongoDbModule.cs
  10. 1
      src/Squidex/Config/Web/WebDependencies.cs
  11. 73
      src/Squidex/Controllers/Api/Apps/AppUsageController.cs
  12. 30
      src/Squidex/Controllers/Api/Apps/Models/UsageDto.cs
  13. 18
      src/Squidex/Controllers/Api/Ping/PingController.cs
  14. 50
      src/Squidex/Pipeline/AppTrackingFilter.cs
  15. 1
      tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  16. 101
      tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

35
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; }
}
}

55
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<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();
}
}
}

6
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<CancellationToken, Task> callback)
public CompletionTimer(int delayInMs, Func<CancellationToken, Task> 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<CancellationToken, Task> callback)

111
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<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;
}
}
}

21
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<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate);
}
}

21
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<IReadOnlyList<StoredUsage>> FindAsync(string key, DateTime fromDate, DateTime toDate);
}
}

29
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;
}
}
}

5
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<IClock>()
.SingleInstance();
builder.RegisterType<BackgroundUsageTracker>()
.As<IUsageTracker>()
.SingleInstance();
builder.RegisterType<HttpContextAccessor>()
.As<IHttpContextAccessor>()
.SingleInstance();

8
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<IPersistedGrantStore>()
.SingleInstance();
builder.RegisterType<MongoUsageStore>()
.WithParameter(ResolvedParameter.ForNamed<IMongoDatabase>(MongoDatabaseRegistration))
.As<IUsageStore>()
.AsSelf()
.SingleInstance();
builder.RegisterType<MongoEventConsumerInfoRepository>()
.WithParameter(ResolvedParameter.ForNamed<IMongoDatabase>(MongoDatabaseRegistration))
.As<IEventConsumerInfoRepository>()

1
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();
}
}

73
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
{
/// <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);
}
}
}

30
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
{
/// <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; }
}
}

18
src/Squidex/Controllers/ContentApi/PingController.cs → 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
{
/// <summary>
/// Makes a ping request.
/// </summary>
[Authorize(Roles = SquidexRoles.AppEditor)]
[ApiExceptionFilter]
[ServiceFilter(typeof(AppFilterAttribute))]
[SwaggerTag("Ping")]
public class PingController : Controller
{
/// <summary>
/// Get ping status.
/// </summary>
/// <returns>
/// 204 => Service ping successful.
/// </returns>
/// <remarks>
/// Can be used to test, if the Squidex API is alive and responding.
/// </remarks>
[HttpGet]
[Route("ping/{app}/")]
public IActionResult GetPing()
{
return Ok();
return NoContent();
}
}
}

50
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<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);
}
}
}
}

1
tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -8,6 +8,7 @@
<ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="4.19.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="1.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.0.0" />

101
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<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…
Cancel
Save