diff --git a/ecs-dotnet.sln b/ecs-dotnet.sln index 8d046b3c..ba13fbd8 100644 --- a/ecs-dotnet.sln +++ b/ecs-dotnet.sln @@ -131,6 +131,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elastic.NLog.Targets.Integr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "playground", "examples\playground\playground.csproj", "{86AEB76A-C210-4250-8541-B349C26C1683}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Extensions.Logging.Console", "src\Elastic.Extensions.Logging.Console\Elastic.Extensions.Logging.Console.csproj", "{E0033468-2448-47F5-8B7A-8DC1F5FF080C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Extensions.Logging.Console.Example", "examples\Elastic.Extensions.Logging.Console.Example\Elastic.Extensions.Logging.Console.Example.csproj", "{9656A08E-9DA6-473A-B3F8-245AC7B81A28}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Extensions.Logging.Common", "src\Elastic.Extensions.Logging.Common\Elastic.Extensions.Logging.Common.csproj", "{5EDF109F-9DFF-4957-8864-BA2702FB78F6}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Serilog.Sinks.Tests", "tests\Elastic.Serilog.Sinks.Tests\Elastic.Serilog.Sinks.Tests.csproj", "{933FD923-A2DC-49E3-B21E-8BA888DB5924}" EndProject Global @@ -283,6 +288,18 @@ Global {86AEB76A-C210-4250-8541-B349C26C1683}.Debug|Any CPU.Build.0 = Debug|Any CPU {86AEB76A-C210-4250-8541-B349C26C1683}.Release|Any CPU.ActiveCfg = Release|Any CPU {86AEB76A-C210-4250-8541-B349C26C1683}.Release|Any CPU.Build.0 = Release|Any CPU + {E0033468-2448-47F5-8B7A-8DC1F5FF080C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0033468-2448-47F5-8B7A-8DC1F5FF080C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0033468-2448-47F5-8B7A-8DC1F5FF080C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0033468-2448-47F5-8B7A-8DC1F5FF080C}.Release|Any CPU.Build.0 = Release|Any CPU + {9656A08E-9DA6-473A-B3F8-245AC7B81A28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9656A08E-9DA6-473A-B3F8-245AC7B81A28}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9656A08E-9DA6-473A-B3F8-245AC7B81A28}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9656A08E-9DA6-473A-B3F8-245AC7B81A28}.Release|Any CPU.Build.0 = Release|Any CPU + {5EDF109F-9DFF-4957-8864-BA2702FB78F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EDF109F-9DFF-4957-8864-BA2702FB78F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EDF109F-9DFF-4957-8864-BA2702FB78F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EDF109F-9DFF-4957-8864-BA2702FB78F6}.Release|Any CPU.Build.0 = Release|Any CPU {933FD923-A2DC-49E3-B21E-8BA888DB5924}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {933FD923-A2DC-49E3-B21E-8BA888DB5924}.Debug|Any CPU.Build.0 = Debug|Any CPU {933FD923-A2DC-49E3-B21E-8BA888DB5924}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -329,6 +346,9 @@ Global {692F8035-F3F9-4714-8C9D-D54AF4CEB0E0} = {7610B796-BB3E-4CB2-8296-79BBFF6D23FC} {D1C3CAFB-A59D-4E3F-ADD1-4CB281E5349D} = {947B298F-9139-4868-B337-729541932E4D} {86AEB76A-C210-4250-8541-B349C26C1683} = {05075402-8669-45BD-913A-BD40A29BBEAB} + {E0033468-2448-47F5-8B7A-8DC1F5FF080C} = {7610B796-BB3E-4CB2-8296-79BBFF6D23FC} + {9656A08E-9DA6-473A-B3F8-245AC7B81A28} = {05075402-8669-45BD-913A-BD40A29BBEAB} + {5EDF109F-9DFF-4957-8864-BA2702FB78F6} = {7610B796-BB3E-4CB2-8296-79BBFF6D23FC} {933FD923-A2DC-49E3-B21E-8BA888DB5924} = {3582B07D-C2B0-49CC-B676-EAF806EB010E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/ecs-dotnet.sln.DotSettings b/ecs-dotnet.sln.DotSettings index 46fdbeb1..7a3e3f04 100644 --- a/ecs-dotnet.sln.DotSettings +++ b/ecs-dotnet.sln.DotSettings @@ -119,7 +119,7 @@ </Entry.Match> <Entry.SortBy> <Kind Is="Member" /> - <Name Is="Enter Pattern Here" /> + <Name /> </Entry.SortBy> </Entry> <Entry DisplayName="Readonly Fields"> @@ -137,7 +137,7 @@ <Entry.SortBy> <Access /> <Readonly /> - <Name Is="Enter Pattern Here" /> + <Name /> </Entry.SortBy> </Entry> <Entry DisplayName="Constructors"> @@ -411,6 +411,7 @@ True True True + True True True False diff --git a/examples/Elastic.Extensions.Logging.Console.Example/Elastic.Extensions.Logging.Console.Example.csproj b/examples/Elastic.Extensions.Logging.Console.Example/Elastic.Extensions.Logging.Console.Example.csproj new file mode 100644 index 00000000..05efcc92 --- /dev/null +++ b/examples/Elastic.Extensions.Logging.Console.Example/Elastic.Extensions.Logging.Console.Example.csproj @@ -0,0 +1,16 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + diff --git a/examples/Elastic.Extensions.Logging.Console.Example/ExampleService.cs b/examples/Elastic.Extensions.Logging.Console.Example/ExampleService.cs new file mode 100644 index 00000000..85da6c6e --- /dev/null +++ b/examples/Elastic.Extensions.Logging.Console.Example/ExampleService.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Elastic.Extensions.Logging.Console.Example; + +/// Simulate work that logs in low volume with some time in between each log call +public class ExampleService : BackgroundService +{ + private readonly ILogger _logger; + + public ExampleService(ILogger logger) => _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken ctx) + { + for (var i = 0; i < 100; i++) + { + if (i % 10 == 0) + _logger.LogWarning("We are logging way too much: {CustomData}", i); + else + _logger.LogInformation("We are logging way too much: {CustomData}", i); + if (i % 100 == 0) + await Task.Delay(1, ctx); + } + } +} diff --git a/examples/Elastic.Extensions.Logging.Console.Example/Program.cs b/examples/Elastic.Extensions.Logging.Console.Example/Program.cs new file mode 100644 index 00000000..e1e08cd5 --- /dev/null +++ b/examples/Elastic.Extensions.Logging.Console.Example/Program.cs @@ -0,0 +1,23 @@ +// See https://aka.ms/new-console-template for more information + +using Elastic.Extensions.Logging.Console; +using Elastic.Extensions.Logging.Console.Example; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +await Host.CreateDefaultBuilder(args) + .UseConsoleLifetime() + .ConfigureAppConfiguration((_, configurationBuilder) => + { + configurationBuilder.SetBasePath(AppDomain.CurrentDomain.BaseDirectory); + }) + .ConfigureLogging((_, loggingBuilder) => loggingBuilder.AddEcsConsole()) + .ConfigureServices((_, services) => + { + services.AddHostedService(); + }) + .Build() + .RunAsync(); + diff --git a/examples/Elastic.Extensions.Logging.Example/Elastic.Extensions.Logging.Example.csproj b/examples/Elastic.Extensions.Logging.Example/Elastic.Extensions.Logging.Example.csproj index 71118347..2d032985 100644 --- a/examples/Elastic.Extensions.Logging.Example/Elastic.Extensions.Logging.Example.csproj +++ b/examples/Elastic.Extensions.Logging.Example/Elastic.Extensions.Logging.Example.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Elastic.Extensions.Logging.Common/Elastic.Extensions.Logging.Common.csproj b/src/Elastic.Extensions.Logging.Common/Elastic.Extensions.Logging.Common.csproj new file mode 100644 index 00000000..81237a3d --- /dev/null +++ b/src/Elastic.Extensions.Logging.Common/Elastic.Extensions.Logging.Common.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0;netstandard2.1 + Common Abstactions For ECS For Microsoft.Extensions.Logging + Transient dependency, do not install directly. Common Abstactions For ECS For Microsoft.Extensions.Logging + Logging;LoggerProvider;Elasticsearch;Console;ELK;Kibana;Logstash;Tracing;Diagnostics;Log;Trace;ECS + enable + enable + True + + + + + + + + + + diff --git a/src/Elastic.Extensions.Logging.Common/ILogEventCreationOptions.cs b/src/Elastic.Extensions.Logging.Common/ILogEventCreationOptions.cs new file mode 100644 index 00000000..efca528b --- /dev/null +++ b/src/Elastic.Extensions.Logging.Common/ILogEventCreationOptions.cs @@ -0,0 +1,20 @@ +using Elastic.CommonSchema; + +namespace Elastic.Extensions.Logging.Common; + +/// +/// +/// +public interface ILogEventCreationOptions : IEcsDocumentCreationOptions +{ + /// + /// Gets or sets additional tags to pass in the message, for example you can tag with the environment name ('Development', + /// 'Production', etc). + /// + string[]? Tags { get; set; } + + /// + /// Gets or sets the separate to use for IList semantic values. + /// + string ListSeparator { get; set; } +} diff --git a/src/Elastic.Extensions.Logging/LogEvent.cs b/src/Elastic.Extensions.Logging.Common/LogEvent.cs similarity index 97% rename from src/Elastic.Extensions.Logging/LogEvent.cs rename to src/Elastic.Extensions.Logging.Common/LogEvent.cs index 7aef62ed..6f8fd21f 100644 --- a/src/Elastic.Extensions.Logging/LogEvent.cs +++ b/src/Elastic.Extensions.Logging.Common/LogEvent.cs @@ -2,13 +2,13 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System; -using System.Collections.Generic; using System.Runtime.Serialization; using System.Text.Json.Serialization; using Elastic.CommonSchema; using Elastic.CommonSchema.Serialization; +// kept in this namespace for bwc +// ReSharper disable once CheckNamespace namespace Elastic.Extensions.Logging { /// diff --git a/src/Elastic.Extensions.Logging.Common/LogEventBuilderExtensions.cs b/src/Elastic.Extensions.Logging.Common/LogEventBuilderExtensions.cs new file mode 100644 index 00000000..4f56e458 --- /dev/null +++ b/src/Elastic.Extensions.Logging.Common/LogEventBuilderExtensions.cs @@ -0,0 +1,212 @@ +using System.Collections; +using System.Text; +using Elastic.CommonSchema; +using Microsoft.Extensions.Logging; + +namespace Elastic.Extensions.Logging.Common; + +/// +public static class LogEventBuilderExtensions +{ + /// + public static void AddScopeValues(this LogEvent logEvent, IExternalScopeProvider? scopeProvider, ILogEventCreationOptions options) + { + void AddScopeValue(TLocalState scope, LogEvent log) + { + if (scope is null) return; + + log.Labels ??= new Labels(); + log.Scopes ??= new List(); + + + var scopeValues = (scope as IEnumerable>)?.ToList(); + var scopeName = scopeValues != null && scopeValues.Any(kv => kv.Key == "{OriginalFormat}") + ? scope.ToString() + : FormatValue(scope, options, 0, scope.GetType().Name); + log.Scopes.Add(scopeName); + + if (scopeValues == null) return; + + foreach (var kvp in scopeValues) + AssignStateOrScopeLabels(logEvent, kvp, options); + } + + scopeProvider?.ForEachScope((o, @event) => AddScopeValue(o, @event), logEvent); + } + + /// + public static void AddStateValues(this LogEvent logEvent, TState state, ILogEventCreationOptions options) + { + if (state is not IEnumerable> stateValues) return; + + foreach (var kvp in stateValues) + AssignStateOrScopeLabels(logEvent, kvp, options); + } + + + private static void AssignStateOrScopeLabels(LogEvent logEvent, KeyValuePair kvp, ILogEventCreationOptions options) + { + if (kvp.Key == "{OriginalFormat}") + { + // we explicitly want this to override, preferring OriginalFormat from current state over scope + logEvent.MessageTemplate = kvp.Value.ToString(); + return; + } + var value = FormatValue(kvp.Value, options); + if (!AssignKnownHttpKeys(logEvent, kvp.Key, value)) + logEvent.AssignField(kvp.Key, value); + } + + private static bool AssignKnownHttpKeys(LogEvent logEvent, string key, object value) + { + switch (key) + { + case "RequestId" when value is string requestId: + logEvent.Http ??= new Http(); + logEvent.Http.RequestId = requestId; + return true; + case "RequestPath" when value is string path: + logEvent.Url ??= new Url(); + logEvent.Url.Path = path; + return true; + // ReSharper disable once UnusedVariable + case "Protocol" when value is string protocol: + // TODO protocol + //logEvent.Http ??= new Http(); + //logEvent.Http. = requestId; + return true; + case "Method" when value is string method: + logEvent.Http ??= new Http(); + logEvent.Http.RequestMethod = method; + return true; + case "ContentType" when value is string contentType: + logEvent.Http ??= new Http(); + logEvent.Http.RequestMimeType = contentType; + return true; + case "ContentLength" when value is string contentLength: + logEvent.Http ??= new Http(); + logEvent.Http.RequestBytes = long.TryParse(contentLength, out var l) ? l : (long?)null; + return true; + case "Scheme" when value is string scheme: + logEvent.Url ??= new Url(); + logEvent.Url.Scheme = scheme; + return true; + case "Host" when value is string host: + logEvent.Url ??= new Url(); + logEvent.Url.Domain = host; + return true; + case "Path": + case "PathBase": + //covered by 'RequestPath' + return true; + case "QueryString" when value is string qs: + logEvent.Url ??= new Url(); + logEvent.Url.Query = qs; + return true; + default: return false; + } + } + + private static string FormatValue(object value, ILogEventCreationOptions options, int depth = 0, string? defaultFallback = null) + { + switch (value) + { + case null: + return string.Empty; + case byte b: + return b.ToString("X2"); + case byte[] bytes: + var builder = new StringBuilder("0x"); + foreach (var b in bytes) builder.Append(b.ToString("X2")); + + return builder.ToString(); + case DateTime dateTime: + if (dateTime.TimeOfDay.Equals(TimeSpan.Zero)) + return dateTime.ToString("yyyy'-'MM'-'dd"); + + return dateTime.ToString("o"); + case DateTimeOffset dateTimeOffset: + return dateTimeOffset.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss.ffffffzzz"); + case string s: + // since 'string' implements IEnumerable, special case it + return s; + default: + // need to special case dictionary before IEnumerable + if (depth < 1 && value is IDictionary dictionary) + return FormatStringDictionary(dictionary, depth, options); + + // if the value implements IEnumerable, build a comma separated string + if (depth < 1 && value is IEnumerable enumerable) + return FormatEnumerable(enumerable, depth, options); + + return defaultFallback ?? value.ToString(); + } + } + + private static string FormatEnumerable(IEnumerable enumerable, int depth, ILogEventCreationOptions options) + { + var stringBuilder = new StringBuilder(); + + // The standard array.ToString() isn't very interesting, so render the elements + depth = depth + 1; + var index = 0; + foreach (var item in enumerable) + { + if (index > 0) stringBuilder.Append(options.ListSeparator); + + var value = FormatValue(item, options, depth); + stringBuilder.Append(value); + index++; + } + + return stringBuilder.ToString(); + } + + private static string FormatStringDictionary(IDictionary dictionary, int depth, ILogEventCreationOptions options) + { + // The standard dictionary.ToString() isn't very interesting, so render the key-value pairs + var stringBuilder = new StringBuilder(); + depth = depth + 1; + var index = 0; + foreach (var kvp in dictionary) + { + if (index > 0) stringBuilder.Append(" "); + + WriteName(stringBuilder, kvp.Key); + stringBuilder.Append('='); + stringBuilder.Append('"'); + WriteValue(stringBuilder, FormatValue(kvp.Value, options, depth)); + stringBuilder.Append('"'); + index++; + } + + return stringBuilder.ToString(); + } + + private static void WriteName(StringBuilder stringBuilder, string name) + { + foreach (var c in name) + { + if (c == ' ') + stringBuilder.Append('_'); + else if (c == '=') + stringBuilder.AppendFormat("_x{0:X2}_", (int)c); + else + stringBuilder.Append(c); + } + } + + private static void WriteValue(StringBuilder stringBuilder, string value) + { + foreach (var c in value) + { + if (c == '"' || c == '\\') + { + stringBuilder.Append('\\'); + stringBuilder.Append(c); + } + else + stringBuilder.Append(c); + } + } +} diff --git a/src/Elastic.Extensions.Logging/LogEventToEcsHelper.cs b/src/Elastic.Extensions.Logging.Common/LogEventToEcsHelper.cs similarity index 55% rename from src/Elastic.Extensions.Logging/LogEventToEcsHelper.cs rename to src/Elastic.Extensions.Logging.Common/LogEventToEcsHelper.cs index 57698bff..8551ee5f 100644 --- a/src/Elastic.Extensions.Logging/LogEventToEcsHelper.cs +++ b/src/Elastic.Extensions.Logging.Common/LogEventToEcsHelper.cs @@ -1,10 +1,13 @@ +using Elastic.CommonSchema; using Microsoft.Extensions.Logging; -namespace Elastic.Extensions.Logging +namespace Elastic.Extensions.Logging.Common { - internal static class LogEventToEcsHelper + /// Extensions for so they can be projected into ECS format in different formats + public static class LogLevelExtensions { - public static int GetSeverity(LogLevel logLevel) => + /// projects to + public static int ToEcsSeverity(this LogLevel logLevel) => logLevel switch { LogLevel.Critical => 2, @@ -17,7 +20,8 @@ public static int GetSeverity(LogLevel logLevel) => _ => 7 }; - public static string GetLogLevelString(LogLevel logLevel) => + /// projects to + public static string ToEcsLogLevelString(this LogLevel logLevel) => logLevel switch { LogLevel.Critical => nameof(LogLevel.Critical), diff --git a/src/Elastic.Extensions.Logging.Common/README.md b/src/Elastic.Extensions.Logging.Common/README.md new file mode 100644 index 00000000..f8275d6e --- /dev/null +++ b/src/Elastic.Extensions.Logging.Common/README.md @@ -0,0 +1,5 @@ +# Elastic.Extensions.Logging.Common + +Transitive dependency for `Elastic.Extensions.Logging` and `Elastic.Extensions.Logging.Console`. + +Should not be installed directly. \ No newline at end of file diff --git a/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatter.cs b/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatter.cs new file mode 100644 index 00000000..e52fd5a5 --- /dev/null +++ b/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatter.cs @@ -0,0 +1,56 @@ +using Elastic.CommonSchema; +using Elastic.Extensions.Logging.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; + +namespace Elastic.Extensions.Logging.Console; + +/// +public sealed class EcsConsoleFormatter : ConsoleFormatter, IDisposable +{ + private readonly IDisposable? _optionsReloadToken; + private EcsConsoleFormatterOptions _options; + + private static Agent? DefaultAgent { get; } = EcsDocument.CreateAgent(typeof(EcsConsoleFormatter)); + + /// + public EcsConsoleFormatter(IOptionsMonitor options) : base("ecs") => + (_optionsReloadToken, _options) = (options.OnChange(ReloadLoggerOptions), options.CurrentValue); + + private void ReloadLoggerOptions(EcsConsoleFormatterOptions options) => + _options = options; + + /// + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + var now = DateTime.UtcNow; + var message = logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception); + if (message is null) + return; + + var logEvent = EcsDocument.CreateNewWithDefaults(now, logEntry.Exception, _options); + var logLevel = logEntry.LogLevel; + var categoryName = logEntry.Category; + var eventId = logEntry.EventId; + logEvent.Log = new Log { Level = logLevel.ToEcsLogLevelString(), Logger = categoryName }; + logEvent.Event = new Event { Action = eventId.Name, Code = eventId.Id.ToString(), Severity = logLevel.ToEcsSeverity() }; + logEvent.Message = message; + + logEvent.Agent = DefaultAgent; + + if (_options.Tags is { Length: > 0 }) logEvent.Tags = _options.Tags; + + if (_options.IncludeScopes) + logEvent.AddScopeValues(scopeProvider, _options); + + // These will overwrite any scope values with the same name + logEvent.AddStateValues(logEntry.State, _options); + + textWriter.WriteLine(logEvent.Serialize()); + } + + /// + public void Dispose() => _optionsReloadToken?.Dispose(); +} diff --git a/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatterOptions.cs b/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatterOptions.cs new file mode 100644 index 00000000..bb37508b --- /dev/null +++ b/src/Elastic.Extensions.Logging.Console/EcsConsoleFormatterOptions.cs @@ -0,0 +1,28 @@ +using Elastic.CommonSchema; +using Elastic.Extensions.Logging.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace Elastic.Extensions.Logging.Console; + +/// +public class EcsConsoleFormatterOptions : ConsoleFormatterOptions, ILogEventCreationOptions +{ + /// + public bool IncludeHost { get; set; } = true; + + /// + public bool IncludeProcess { get; set; } = true; + + /// + public bool IncludeUser { get; set; } = true; + + /// + public bool IncludeActivityData { get; set; } = true; + + /// + public string[]? Tags { get; set; } + + /// + public string ListSeparator { get; set; } = ", "; +} diff --git a/src/Elastic.Extensions.Logging.Console/Elastic.Extensions.Logging.Console.csproj b/src/Elastic.Extensions.Logging.Console/Elastic.Extensions.Logging.Console.csproj new file mode 100644 index 00000000..24cd88a8 --- /dev/null +++ b/src/Elastic.Extensions.Logging.Console/Elastic.Extensions.Logging.Console.csproj @@ -0,0 +1,22 @@ + + + + netstandard2.0;netstandard2.1 + enable + ECS Console Logger for Microsoft.Extensions.Logging + ECS Console Logger for Microsoft.Extensions.Logging. Writes Elastic Common Schema (ECS), with semantic logging of structured data from message and scope values to console out, use filebeat/Elastic-Agent to send these to Elastic + Logging;LoggerProvider;Elasticsearch;Console;ELK;Kibana;Logstash;Tracing;Diagnostics;Log;Trace;ECS + enable + True + + + + + + + + + + + + diff --git a/src/Elastic.Extensions.Logging.Console/LoggingBuilderExtensions.cs b/src/Elastic.Extensions.Logging.Console/LoggingBuilderExtensions.cs new file mode 100644 index 00000000..d354b684 --- /dev/null +++ b/src/Elastic.Extensions.Logging.Console/LoggingBuilderExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Logging; + +namespace Elastic.Extensions.Logging.Console; + +/// Extensions to to ease setting up ECS formatted logs to console. +public static class LoggingBuilderExtensions +{ + /// Adds ECS output to console output + public static ILoggingBuilder AddEcsConsole(this ILoggingBuilder builder, LogLevel stdErrorThreshold = LogLevel.Warning, Action? configure = null) + { + builder.AddConsole(c=> + { + c.FormatterName = "ecs"; + c.LogToStandardErrorThreshold = stdErrorThreshold; + }); + builder.AddConsoleFormatter(configure ?? (_ => { })); + return builder; + } +} diff --git a/src/Elastic.Extensions.Logging.Console/README.md b/src/Elastic.Extensions.Logging.Console/README.md new file mode 100644 index 00000000..02c0a5c5 --- /dev/null +++ b/src/Elastic.Extensions.Logging.Console/README.md @@ -0,0 +1,24 @@ +# Elastic.Extensions.Logging.Console + +This package includes formatters and extension methods for `Microsoft.Extensions.Logging.Console` to make it easy to write ECS formatted logs to consoleoutput. + +May be used with `Elastic.Extensions.Logging` to write ECS documents directly to Elasticsearch / Elastic Cloud. + + +## Usage + +The console logging provider and formatter can be set up using a simple extension method. + +```csharp +.ConfigureLogging((_, loggingBuilder) => loggingBuilder.AddEcsConsole()) +``` + +Or indirectly using the types provided in this package: + +```csharp +.ConfigureLogging((_, loggingBuilder) => +{ + loggingBuilder.AddConsole(c=> c.FormatterName = "ecs"); + loggingBuilder.AddConsoleFormatter(); +}) +``` \ No newline at end of file diff --git a/src/Elastic.Extensions.Logging/Elastic.Extensions.Logging.csproj b/src/Elastic.Extensions.Logging/Elastic.Extensions.Logging.csproj index d4feadce..3b153ac8 100644 --- a/src/Elastic.Extensions.Logging/Elastic.Extensions.Logging.csproj +++ b/src/Elastic.Extensions.Logging/Elastic.Extensions.Logging.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Elastic.Extensions.Logging/ElasticsearchLogger.cs b/src/Elastic.Extensions.Logging/ElasticsearchLogger.cs index 17f1ba7f..1e6290a4 100644 --- a/src/Elastic.Extensions.Logging/ElasticsearchLogger.cs +++ b/src/Elastic.Extensions.Logging/ElasticsearchLogger.cs @@ -10,6 +10,7 @@ using Elastic.CommonSchema; using Elastic.Channels; using Elastic.Channels.Diagnostics; +using Elastic.Extensions.Logging.Common; using Elastic.Extensions.Logging.Options; using Microsoft.Extensions.Logging; @@ -42,16 +43,20 @@ internal ElasticsearchLogger( _scopeProvider = scopeProvider; } + private class EmptyDisposable : IDisposable + { + public void Dispose() { } + } + private readonly IDisposable _emptyScope = new EmptyDisposable(); + /// - public IDisposable? BeginScope(TState state) => _scopeProvider?.Push(state); + public IDisposable BeginScope(TState state) => _scopeProvider?.Push(state) ?? _emptyScope; /// public bool IsEnabled(LogLevel logLevel) => _options.IsEnabled; /// - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, - Func formatter - ) + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { try { @@ -71,102 +76,6 @@ Func formatter } } - private void AddScopeValues(LogEvent logEvent) - { - if (!_options.IncludeScopes) return; - - void AddScopeValue(TState scope, LogEvent log) - { - if (scope is null) return; - log.Labels ??= new Labels(); - log.Scopes ??= new List(); - - - var scopeValues = (scope as IEnumerable>)?.ToList(); - var scopeName = scopeValues != null && scopeValues.Any(kv => kv.Key == "{OriginalFormat}") ? scope.ToString() : FormatValue(scope, 0, scope.GetType().Name); - log.Scopes.Add(scopeName); - - if (scopeValues == null) return; - - foreach (var kvp in scopeValues) - AssignStateOrScopeLabels(logEvent, kvp); - } - - _scopeProvider?.ForEachScope((o, @event) => AddScopeValue(o, @event), logEvent); - } - - private void AddStateValues(TState state, LogEvent logEvent) - { - var stateValues = state as IEnumerable>; - if (stateValues == null) return; - - foreach (var kvp in stateValues) - AssignStateOrScopeLabels(logEvent, kvp); - } - - private void AssignStateOrScopeLabels(LogEvent logEvent, KeyValuePair kvp) - { - if (kvp.Key == "{OriginalFormat}") - { - // we explicitly want this to override, preferring OriginalFormat from current state over scope - logEvent.MessageTemplate = kvp.Value.ToString(); - return; - } - var value = FormatValue(kvp.Value); - if (!AssignKnownHttpKeys(logEvent, kvp.Key, value)) - logEvent.AssignField(kvp.Key, value); - } - - private static bool AssignKnownHttpKeys(LogEvent logEvent, string key, object value) - { - switch (key) - { - case "RequestId" when value is string requestId: - logEvent.Http ??= new Http(); - logEvent.Http.RequestId = requestId; - return true; - case "RequestPath" when value is string path: - logEvent.Url ??= new Url(); - logEvent.Url.Path = path; - return true; - // ReSharper disable once UnusedVariable - case "Protocol" when value is string protocol: - // TODO protocol - //logEvent.Http ??= new Http(); - //logEvent.Http. = requestId; - return true; - case "Method" when value is string method: - logEvent.Http ??= new Http(); - logEvent.Http.RequestMethod = method; - return true; - case "ContentType" when value is string contentType: - logEvent.Http ??= new Http(); - logEvent.Http.RequestMimeType = contentType; - return true; - case "ContentLength" when value is string contentLength: - logEvent.Http ??= new Http(); - logEvent.Http.RequestBytes = long.TryParse(contentLength, out var l) ? l : (long?)null; - return true; - case "Scheme" when value is string scheme: - logEvent.Url ??= new Url(); - logEvent.Url.Scheme = scheme; - return true; - case "Host" when value is string host: - logEvent.Url ??= new Url(); - logEvent.Url.Domain = host; - return true; - case "Path": - case "PathBase": - //covered by 'RequestPath' - return true; - case "QueryString" when value is string qs: - logEvent.Url ??= new Url(); - logEvent.Url.Query = qs; - return true; - default: return false; - } - } - private static Agent? DefaultAgent { get; } = EcsDocument.CreateAgent(typeof(ElasticsearchLogger)); private LogEvent BuildLogEvent(string categoryName, LogLevel logLevel, @@ -178,121 +87,20 @@ Func formatter var logEvent = EcsDocument.CreateNewWithDefaults(timestamp, exception, _options); logEvent.Message = formatter(state, exception!); - logEvent.Log = new Log { Level = LogEventToEcsHelper.GetLogLevelString(logLevel), Logger = categoryName }; - logEvent.Event = new Event { Action = eventId.Name, Code = eventId.Id.ToString(), Severity = LogEventToEcsHelper.GetSeverity(logLevel) }; + logEvent.Log = new Log { Level = logLevel.ToEcsLogLevelString(), Logger = categoryName }; + logEvent.Event = new Event { Action = eventId.Name, Code = eventId.Id.ToString(), Severity = logLevel.ToEcsSeverity() }; logEvent.Agent = DefaultAgent; - if (_options.Tags != null && _options.Tags.Length > 0) logEvent.Tags = _options.Tags; + if (_options.Tags is { Length: > 0 }) logEvent.Tags = _options.Tags; - if (_options.IncludeScopes) AddScopeValues(logEvent); + if (_options.IncludeScopes) + logEvent.AddScopeValues(_scopeProvider, _options); // These will overwrite any scope values with the same name - AddStateValues(state, logEvent); + logEvent.AddStateValues(state, _options); return logEvent; } - private string FormatEnumerable(IEnumerable enumerable, int depth) - { - var stringBuilder = new StringBuilder(); - - // The standard array.ToString() isn't very interesting, so render the elements - depth = depth + 1; - var index = 0; - foreach (var item in enumerable) - { - if (index > 0) stringBuilder.Append(_options.ListSeparator); - - var value = FormatValue(item, depth); - stringBuilder.Append(value); - index++; - } - - return stringBuilder.ToString(); - } - - private string FormatStringDictionary(IDictionary dictionary, int depth) - { - // The standard dictionary.ToString() isn't very interesting, so render the key-value pairs - var stringBuilder = new StringBuilder(); - depth = depth + 1; - var index = 0; - foreach (var kvp in dictionary) - { - if (index > 0) stringBuilder.Append(" "); - - WriteName(stringBuilder, kvp.Key); - stringBuilder.Append('='); - stringBuilder.Append('"'); - WriteValue(stringBuilder, FormatValue(kvp.Value, depth)); - stringBuilder.Append('"'); - index++; - } - - return stringBuilder.ToString(); - } - - private string FormatValue(object value, int depth = 0, string? defaultFallback = null) - { - switch (value) - { - case null: - return string.Empty; - case byte b: - return b.ToString("X2"); - case byte[] bytes: - var builder = new StringBuilder("0x"); - foreach (var b in bytes) builder.Append(b.ToString("X2")); - - return builder.ToString(); - case DateTime dateTime: - if (dateTime.TimeOfDay.Equals(TimeSpan.Zero)) - return dateTime.ToString("yyyy'-'MM'-'dd"); - - return dateTime.ToString("o"); - case DateTimeOffset dateTimeOffset: - return dateTimeOffset.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss.ffffffzzz"); - case string s: - // since 'string' implements IEnumerable, special case it - return s; - default: - // need to special case dictionary before IEnumerable - if (depth < 1 && value is IDictionary dictionary) - return FormatStringDictionary(dictionary, depth); - - // if the value implements IEnumerable, build a comma separated string - if (depth < 1 && value is IEnumerable enumerable) - return FormatEnumerable(enumerable, depth); - - return defaultFallback ?? value.ToString(); - } - } - - private static void WriteName(StringBuilder stringBuilder, string name) - { - foreach (var c in name) - { - if (c == ' ') - stringBuilder.Append('_'); - else if (c == '=') - stringBuilder.AppendFormat("_x{0:X2}_", (int)c); - else - stringBuilder.Append(c); - } - } - - private static void WriteValue(StringBuilder stringBuilder, string value) - { - foreach (var c in value) - { - if (c == '"' || c == '\\') - { - stringBuilder.Append('\\'); - stringBuilder.Append(c); - } - else - stringBuilder.Append(c); - } - } } } diff --git a/src/Elastic.Extensions.Logging/Options/ElasticsearchLoggerOptions.cs b/src/Elastic.Extensions.Logging/Options/ElasticsearchLoggerOptions.cs index 6c1f9336..75737599 100644 --- a/src/Elastic.Extensions.Logging/Options/ElasticsearchLoggerOptions.cs +++ b/src/Elastic.Extensions.Logging/Options/ElasticsearchLoggerOptions.cs @@ -1,4 +1,5 @@ using Elastic.CommonSchema; +using Elastic.Extensions.Logging.Common; using Elastic.Ingest.Elasticsearch; using Elastic.Transport; @@ -7,7 +8,7 @@ namespace Elastic.Extensions.Logging.Options /// /// Provide options to to control how data gets written to Elasticsearch /// - public class ElasticsearchLoggerOptions : IEcsDocumentCreationOptions + public class ElasticsearchLoggerOptions : ILogEventCreationOptions { /// /// Gets or sets a flag indicating whether host details should be included in the message. Defaults to true.