Skip to content

Commit 32bd611

Browse files
committed
Add Analytics APIs
1 parent a1cf6c2 commit 32bd611

12 files changed

Lines changed: 431 additions & 28 deletions

File tree

ServiceStack.Text/src/ServiceStack.Text/DateTimeExtensions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,20 @@ public static DateTime EndOfLastMonth(this DateTime from)
223223
{
224224
return new DateTime(from.Date.Year, from.Date.Month, 1).AddDays(-1);
225225
}
226+
227+
/// <summary>
228+
/// Creates a new DateTime instance with the specified day of the month, while preserving the original time.
229+
/// </summary>
230+
/// <param name="from">The original DateTime.</param>
231+
/// <param name="day">The new day of the month (1-31).</param>
232+
/// <returns>A new DateTime with the updated day.</returns>
233+
/// <exception cref="ArgumentOutOfRangeException">Thrown if the specified day is invalid for the month.</exception>
234+
public static DateTime WithDay(this DateTime from, int day)
235+
{
236+
if (day < 1 || day > DateTime.DaysInMonth(from.Year, from.Month))
237+
throw new ArgumentOutOfRangeException(nameof(day),
238+
$"Day must be between 1 and {DateTime.DaysInMonth(from.Year, from.Month)} for the month of {from:MMMM}.");
239+
240+
return new DateTime(from.Year, from.Month, day, from.Hour, from.Minute, from.Second, from.Millisecond, from.Kind);
241+
}
226242
}

ServiceStack.Text/src/ServiceStack.Text/HttpStatus.cs

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,14 @@ public static string GetStatusDescription(int statusCode)
1717
}
1818

1919
private static readonly string[][] Descriptions =
20-
{
20+
[
2121
null,
22-
new[]
23-
{
22+
[
2423
/* 100 */ "Continue",
2524
/* 101 */ "Switching Protocols",
2625
/* 102 */ "Processing"
27-
},
28-
new[]
29-
{
26+
],
27+
[
3028
/* 200 */ "OK",
3129
/* 201 */ "Created",
3230
/* 202 */ "Accepted",
@@ -35,9 +33,8 @@ public static string GetStatusDescription(int statusCode)
3533
/* 205 */ "Reset Content",
3634
/* 206 */ "Partial Content",
3735
/* 207 */ "Multi-Status"
38-
},
39-
new[]
40-
{
36+
],
37+
[
4138
/* 300 */ "Multiple Choices",
4239
/* 301 */ "Moved Permanently",
4340
/* 302 */ "Found",
@@ -46,9 +43,8 @@ public static string GetStatusDescription(int statusCode)
4643
/* 305 */ "Use Proxy",
4744
/* 306 */ string.Empty,
4845
/* 307 */ "Temporary Redirect"
49-
},
50-
new[]
51-
{
46+
],
47+
[
5248
/* 400 */ "Bad Request",
5349
/* 401 */ "Unauthorized",
5450
/* 402 */ "Payment Required",
@@ -74,9 +70,8 @@ public static string GetStatusDescription(int statusCode)
7470
/* 422 */ "Unprocessable Entity",
7571
/* 423 */ "Locked",
7672
/* 424 */ "Failed Dependency"
77-
},
78-
new[]
79-
{
73+
],
74+
[
8075
/* 500 */ "Internal Server Error",
8176
/* 501 */ "Not Implemented",
8277
/* 502 */ "Bad Gateway",
@@ -85,6 +80,6 @@ public static string GetStatusDescription(int statusCode)
8580
/* 505 */ "Http Version Not Supported",
8681
/* 506 */ string.Empty,
8782
/* 507 */ "Insufficient Storage"
88-
}
89-
};
83+
]
84+
];
9085
}

ServiceStack/src/ServiceStack.Extensions/Auth/IdentityAuth.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,26 @@ async Task<IdentityResult> RemoveClaims(UserManager<TUser> userManager)
618618
}
619619
}
620620

621+
public async Task<List<Dictionary<string,object>>> GetUsersByIdsAsync(List<string> ids, IRequest? request = null)
622+
{
623+
var typedIds = ids.Map(x => x.ConvertTo<TKey>());
624+
if (request == null)
625+
{
626+
var scopeFactory = ServiceStackHost.Instance.GetApplicationServices().GetRequiredService<IServiceScopeFactory>();
627+
using var scope = scopeFactory.CreateScope();
628+
using var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
629+
630+
var users = await userManager.Users.Where(x => typedIds.Contains(x.Id)).ToListAsync().ConfigAwait();
631+
return users.Map(x => x.ToObjectDictionary());
632+
}
633+
else
634+
{
635+
var userManager = request.GetServiceProvider().GetRequiredService<UserManager<TUser>>();
636+
var users = await userManager.Users.Where(x => typedIds.Contains(x.Id)).ToListAsync().ConfigAwait();
637+
return users.Map(x => x.ToObjectDictionary());
638+
}
639+
}
640+
621641
public Task<IList<Claim>> GetClaimsByIdAsync(string userId, IRequest? request = null) =>
622642
GetClaimsAsync(async userManager => await GetClaimsAsync(await userManager.FindByIdAsync(userId)).ConfigAwait(), request);
623643

ServiceStack/src/ServiceStack.Jobs/SqliteRequestLogger.cs

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
using System.Data;
44
using Microsoft.Extensions.DependencyInjection;
55
using Microsoft.Extensions.Logging;
6+
using ServiceStack.Admin;
67
using ServiceStack.Data;
78
using ServiceStack.DataAnnotations;
89
using ServiceStack.Host;
910
using ServiceStack.OrmLite;
11+
using ServiceStack.Text;
1012
using ServiceStack.Web;
1113

1214
namespace ServiceStack.Jobs;
@@ -75,7 +77,7 @@ public object Any(AdminQueryRequestLogs request)
7577
}
7678

7779
public class SqliteRequestLogger : InMemoryRollingRequestLogger, IRequiresSchema,
78-
IRequireRegistration, IConfigureServices
80+
IRequireRegistration, IConfigureServices, IRequireAnalytics
7981
{
8082
private static readonly object dbWrites = Locks.RequestsDb;
8183
public string DbDir { get; set; } = "App_Data/requests";
@@ -309,4 +311,198 @@ public static RequestLogEntry ToRequestLogEntry(RequestLog from)
309311
Meta = from.Meta,
310312
};
311313
}
314+
public static int AnalyticsBatchSize { get; set; } = 1000;
315+
316+
public AnalyticsReports GetAnalyticsReports(DateTime month)
317+
{
318+
// op,user,tag,status,day,apikey,time(ms 0-50,51-100,101-200ms,1-2s,2s-5s,5s+)
319+
var ret = new AnalyticsReports();
320+
using var db = OpenMonthDb(month);
321+
List<RequestLog> batch = [];
322+
long lastPk = 0;
323+
var metadata = HostContext.Metadata;
324+
325+
void Add(Dictionary<string, RequestSummary> results, string name, RequestLog log)
326+
{
327+
var summary = results.TryGetValue(name, out var existing)
328+
? existing
329+
: results[name] = new();
330+
331+
summary.Requests += 1;
332+
summary.Duration += log.RequestDuration.TotalMilliseconds;
333+
summary.RequestLength += log.RequestBody?.Length ?? 0;
334+
}
335+
void AddSummary(Dictionary<string, RequestSummary> results, string name, RequestSummary apiSummary)
336+
{
337+
var summary = results.TryGetValue(name, out var existing)
338+
? existing
339+
: results[name] = new();
340+
summary.Requests += apiSummary.Requests;
341+
summary.Duration += apiSummary.Duration;
342+
summary.RequestLength += apiSummary.RequestLength;
343+
}
344+
345+
do {
346+
batch = db.Select(
347+
db.From<RequestLog>()
348+
.Where(x => x.Id > lastPk)
349+
.OrderBy(x => x.Id)
350+
.Limit(AnalyticsBatchSize));
351+
foreach (var requestLog in batch)
352+
{
353+
Add(ret.Apis, requestLog.Request ?? requestLog.OperationName, requestLog);
354+
if (requestLog.StatusCode > 0)
355+
{
356+
var apiLog = ret.Apis[requestLog.Request ?? requestLog.OperationName];
357+
apiLog.Status ??= new();
358+
apiLog.Status[requestLog.StatusCode] = apiLog.Status.TryGetValue(requestLog.StatusCode, out var existing)
359+
? existing + 1
360+
: 1;
361+
}
362+
363+
if (requestLog.UserAuthId != null)
364+
{
365+
Add(ret.Users, requestLog.UserAuthId, requestLog);
366+
if (requestLog.Meta?.TryGetValue("username", out var username) == true)
367+
{
368+
ret.Users[requestLog.UserAuthId].Name = username;
369+
}
370+
}
371+
372+
if (requestLog.StatusCode > 0)
373+
{
374+
Add(ret.Status, requestLog.StatusCode.ToString(), requestLog);
375+
}
376+
377+
Add(ret.Days, requestLog.DateTime.Day.ToString(), requestLog);
378+
379+
if ((requestLog.Headers.TryGetValue(HttpHeaders.Authorization, out var authorization) && authorization.StartsWith("ak-")) ||
380+
requestLog.Meta?.TryGetValue("apikey", out authorization) == true)
381+
{
382+
Add(ret.ApiKeys, authorization, requestLog);
383+
}
384+
385+
if (requestLog.IpAddress != null)
386+
Add(ret.IpAddresses, requestLog.IpAddress, requestLog);
387+
388+
//(ms 0-50,51-100,101-200ms,1-2s,2s-5s,5s+)
389+
int[] msRanges = [50, 100, 200, 1000, 2000, 5000, 30000];
390+
var totalMs = (int)requestLog.RequestDuration.TotalMilliseconds;
391+
392+
var added = false;
393+
foreach (var range in msRanges)
394+
{
395+
if (totalMs < range)
396+
{
397+
ret.DurationRange[range.ToString()] = ret.DurationRange.TryGetValue(range.ToString(), out var duration)
398+
? duration + 1
399+
: 1;
400+
added = true;
401+
break;
402+
}
403+
}
404+
if (!added)
405+
{
406+
var lastRange = ">" + msRanges.Last();
407+
ret.DurationRange[lastRange] = ret.DurationRange.TryGetValue(lastRange, out var duration)
408+
? duration + 1
409+
: 1;
410+
}
411+
lastPk = requestLog.Id;
412+
}
413+
} while(batch.Count >= AnalyticsBatchSize);
414+
415+
foreach (var entry in ret.Status)
416+
{
417+
if (int.TryParse(entry.Key, out var status))
418+
{
419+
var desc = HttpStatus.GetStatusDescription(status);
420+
if (!string.IsNullOrEmpty(desc))
421+
{
422+
entry.Value.Name = desc;
423+
}
424+
}
425+
}
426+
foreach (var requestDto in ret.Apis.Keys)
427+
{
428+
var requestType = metadata.GetRequestType(requestDto);
429+
if (requestType != null)
430+
{
431+
var op = metadata.GetOperation(requestType);
432+
if (op != null)
433+
{
434+
foreach (var tag in op.Tags)
435+
{
436+
AddSummary(ret.Tags, tag, ret.Apis[requestDto]);
437+
}
438+
}
439+
}
440+
}
441+
442+
void Clean(Dictionary<string, RequestSummary> results)
443+
{
444+
foreach (var entry in results.Values)
445+
{
446+
entry.Duration = Math.Floor(entry.Duration);
447+
}
448+
}
449+
450+
Clean(ret.Apis);
451+
Clean(ret.Users);
452+
Clean(ret.Tags);
453+
Clean(ret.Status);
454+
Clean(ret.Days);
455+
Clean(ret.ApiKeys);
456+
Clean(ret.IpAddresses);
457+
458+
return ret;
459+
}
460+
461+
public Dictionary<string, long> GetApiAnalytics(DateTime month, AnalyticsType type, string value)
462+
{
463+
using var db = OpenMonthDb(month);
464+
List<RequestLog> batch = [];
465+
long lastPk = 0;
466+
467+
var ret = new Dictionary<string, long>();
468+
469+
do
470+
{
471+
var q = db.From<RequestLog>()
472+
.Where(x => x.Id > lastPk);
473+
474+
if (type == AnalyticsType.User)
475+
{
476+
q.And(x => x.UserAuthId == value);
477+
}
478+
else if (type == AnalyticsType.Day)
479+
{
480+
var day = value.ToInt();
481+
var from = month.WithDay(day);
482+
var to = from.AddDays(1);
483+
q.And(x => x.DateTime >= from && x.DateTime < to);
484+
}
485+
else if (type == AnalyticsType.ApiKey)
486+
{
487+
q.And("Headers LIKE {0}", $"%Bearer {value}%");
488+
}
489+
else if (type == AnalyticsType.IpAddress)
490+
{
491+
q.And(x => x.IpAddress == value);
492+
}
493+
494+
batch = db.Select(q
495+
.OrderBy(x => x.Id)
496+
.Limit(AnalyticsBatchSize));
497+
foreach (var requestLog in batch)
498+
{
499+
var op = requestLog.Request ?? requestLog.OperationName;
500+
ret[op] = ret.TryGetValue(op, out var existing)
501+
? existing + 1
502+
: 1;
503+
lastPk = requestLog.Id;
504+
}
505+
} while(batch.Count >= AnalyticsBatchSize);
506+
return ret;
507+
}
312508
}

0 commit comments

Comments
 (0)