// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using EpicGames.Core; using HordeServer.Server; using HordeServer.Utilities; using Microsoft.Extensions.Logging; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; namespace HordeServer.Auditing { class AuditLog : IAuditLog, IAsyncDisposable { class AuditLogMessage : IAuditLogMessage { public ObjectId Id { get; set; } [BsonElement("s")] public TSubject Subject { get; set; } [BsonElement("t")] public DateTime TimeUtc { get; set; } [BsonElement("l")] public LogLevel Level { get; set; } [BsonElement("d")] public string Data { get; set; } public AuditLogMessage() { Subject = default!; Data = String.Empty; } public AuditLogMessage(TSubject subject, DateTime timeUtc, LogLevel level, string data) { Id = ObjectId.GenerateNewId(); Subject = subject; TimeUtc = timeUtc; Level = level; Data = data; } } class AuditLogChannel : IAuditLogChannel { sealed class Scope : IDisposable { readonly AuditLogChannel _owner; public LogEvent Message { get; } public Scope(AuditLogChannel owner, LogEvent message) { _owner = owner; Message = message; _owner._scopes.Add(this); } public void Dispose() => _owner._scopes.Remove(this); } public readonly AuditLog Outer; public TSubject Subject { get; } readonly List _scopes = new List(); public AuditLogChannel(AuditLog outer, TSubject subject) { Outer = outer; Subject = subject; } public IDisposable? BeginScope(TState state) where TState : notnull => new Scope(this, LogEvent.FromState(LogLevel.Information, default, state, null, (x, y) => x?.ToString() ?? String.Empty)); public bool IsEnabled(LogLevel logLevel) => true; LogEvent CreateEvent(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { LogEvent logEvent = LogEvent.FromState(logLevel, eventId, state, exception, formatter); if (_scopes.Count > 0) { StringBuilder message = new StringBuilder(); StringBuilder format = new StringBuilder(); Dictionary properties = new Dictionary(StringComparer.Ordinal); for (int idx = 0; idx < _scopes.Count; idx++) { message.Append('['); format.Append('['); AppendMessage(_scopes[idx].Message, idx + 1, message, format, properties); message.Append(']'); format.Append(']'); } message.Append(' '); format.Append(' '); AppendMessage(logEvent, 0, message, format, properties); logEvent.Message = message.ToString(); logEvent.Format = format.ToString(); logEvent.Properties = properties; } return logEvent; } static void AppendMessage(LogEvent logEvent, int id, StringBuilder message, StringBuilder format, Dictionary properties) { message.Append(logEvent.Message); if (logEvent.Format == null) { format.Append($"{{_scope{id}}}"); properties.Add($"_scope{id}", logEvent.Message); } else { format.Append(logEvent.Format); if (logEvent.Properties != null) { foreach (KeyValuePair item in logEvent.Properties) { properties[item.Key] = item.Value; } } } } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { DateTime time = DateTime.UtcNow; LogEvent logEvent = CreateEvent(logLevel, eventId, state, exception, formatter); string data = logEvent.ToJson(); AuditLogMessage message = new AuditLogMessage(Subject, time, logLevel, data); Outer._messageChannel.Writer.TryWrite(message); #pragma warning disable CA2254 // Template should be a static expression using (IDisposable? _ = Outer._logger.BeginScope($"Subject: {{{Outer._subjectProperty}}}", Subject)) { Outer._logger.Log(logLevel, eventId, state, exception, formatter); } #pragma warning restore CA2254 // Template should be a static expression } public IAsyncEnumerable FindAsync(DateTime? minTime, DateTime? maxTime, int? index, int? count, CancellationToken cancellationToken = default) => Outer.FindAsync(Subject, minTime, maxTime, index, count, cancellationToken); public Task DeleteAsync(DateTime? minTime, DateTime? maxTime, CancellationToken cancellationToken = default) => Outer.DeleteAsync(Subject, minTime, maxTime, cancellationToken); public Task FlushAsync(CancellationToken cancellationToken) => Outer.FlushAsync(cancellationToken); } readonly IMongoCollection _messages; readonly Channel _messageChannel; readonly string _subjectProperty; readonly ILogger _logger; readonly BackgroundTask _backgroundTask; TaskCompletionSource? _flushEvent; public IAuditLogChannel this[TSubject subject] => new AuditLogChannel(this, subject); public AuditLog(IMongoService mongoService, string collectionName, string subjectProperty, ILogger logger) { List> indexes = new List>(); indexes.Add(builder => builder.Ascending(x => x.Subject).Descending(x => x.TimeUtc)); _messages = mongoService.GetCollection(collectionName, indexes); _messageChannel = Channel.CreateUnbounded(); _subjectProperty = subjectProperty; _logger = logger; _backgroundTask = BackgroundTask.StartNew(ctx => WriteMessagesAsync(ctx)); } public async ValueTask DisposeAsync() { _messageChannel.Writer.TryComplete(); await _backgroundTask.DisposeAsync(); } /// /// Flush any pending messages to database /// Exposed as internal for use in tests /// internal async Task FlushMessagesInternalAsync(CancellationToken cancellationToken) { List newMessages = new(); while (_messageChannel.Reader.TryRead(out AuditLogMessage? newMessage)) { if (newMessage != null) { newMessages.Add(newMessage); } } if (newMessages.Count > 0) { await _messages.InsertManyAsync(newMessages, null, cancellationToken); } return newMessages.Count; } async Task WriteMessagesAsync(CancellationToken cancellationToken) { while (await _messageChannel.Reader.WaitToReadAsync(cancellationToken)) { TaskCompletionSource? flushEvent = Interlocked.Exchange(ref _flushEvent, null); List newMessages = new(); while (_messageChannel.Reader.TryRead(out AuditLogMessage? newMessage)) { if (newMessage != null) { newMessages.Add(newMessage); } } if (newMessages.Count > 0) { await _messages.InsertManyAsync(newMessages, null, cancellationToken); } flushEvent?.TrySetResult(); } } public async Task FlushAsync(CancellationToken cancellationToken) { // Get the existing task completion source, or create a new one TaskCompletionSource newTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); TaskCompletionSource? tcs = Interlocked.CompareExchange(ref _flushEvent, newTcs, null) ?? newTcs; // Force the background task to run once await _messageChannel.Writer.WriteAsync(null, cancellationToken); // Wait for the event to trigger await tcs.Task; } async IAsyncEnumerable> FindAsync(TSubject subject, DateTime? minTime = null, DateTime? maxTime = null, int? index = null, int? count = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { FilterDefinition filter = Builders.Filter.Eq(x => x.Subject, subject); if (minTime != null) { filter &= Builders.Filter.Gte(x => x.TimeUtc, minTime.Value); } if (maxTime != null) { filter &= Builders.Filter.Lte(x => x.TimeUtc, maxTime.Value); } using (IAsyncCursor cursor = await _messages.Find(filter).SortByDescending(x => x.TimeUtc).Range(index, count).ToCursorAsync(cancellationToken)) { while (await cursor.MoveNextAsync(cancellationToken)) { foreach (AuditLogMessage message in cursor.Current) { yield return message; } } } } async Task DeleteAsync(TSubject subject, DateTime? minTime = null, DateTime? maxTime = null, CancellationToken cancellationToken = default) { FilterDefinition filter = Builders.Filter.Eq(x => x.Subject, subject); if (minTime != null) { filter &= Builders.Filter.Gte(x => x.TimeUtc, minTime.Value); } if (maxTime != null) { filter &= Builders.Filter.Lte(x => x.TimeUtc, maxTime.Value); } DeleteResult result = await _messages.DeleteManyAsync(filter, cancellationToken); return result.DeletedCount; } } class AuditLogFactory : IAuditLogFactory { readonly IMongoService _mongoService; readonly ILogger> _logger; public AuditLogFactory(IMongoService mongoService, ILogger> logger) { _mongoService = mongoService; _logger = logger; } public IAuditLog Create(string collectionName, string subjectProperty) { return new AuditLog(_mongoService, collectionName, subjectProperty, _logger); } } }