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.