diff --git a/src/OpenTelemetry/Logs/LogEmitter.cs b/src/OpenTelemetry/Logs/LogEmitter.cs new file mode 100644 index 00000000000..30bbd916ce2 --- /dev/null +++ b/src/OpenTelemetry/Logs/LogEmitter.cs @@ -0,0 +1,67 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Logs +{ + /// + /// LogEmitter implementation. + /// + /// + /// Spec reference: LogEmitter. + /// + internal sealed class LogEmitter + { + private readonly OpenTelemetryLoggerProvider loggerProvider; + + internal LogEmitter(OpenTelemetryLoggerProvider loggerProvider) + { + Guard.ThrowIfNull(loggerProvider); + + this.loggerProvider = loggerProvider; + } + + /// + /// Emit a . + /// + /// . + /// . + public void Log(in LogRecordData data, in LogRecordAttributeList attributes = default) + { + var provider = this.loggerProvider; + var processor = provider.Processor; + if (processor != null) + { + var pool = provider.LogRecordPool; + + var logRecord = pool.Rent(); + + logRecord.Data = data; + + attributes.ApplyToLogRecord(logRecord); + + processor.OnEnd(logRecord); + + // Attempt to return the LogRecord to the pool. This will no-op + // if a batch exporter has added a reference. + pool.Return(logRecord); + } + } + } +} diff --git a/src/OpenTelemetry/Logs/LogRecordAttributeList.cs b/src/OpenTelemetry/Logs/LogRecordAttributeList.cs new file mode 100644 index 00000000000..385ba8f108e --- /dev/null +++ b/src/OpenTelemetry/Logs/LogRecordAttributeList.cs @@ -0,0 +1,326 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Logs +{ + /// + /// Stores attributes to be added to a log message. + /// + internal struct LogRecordAttributeList : IReadOnlyList> + { + internal const int OverflowAdditionalCapacity = 8; + internal List>? OverflowAttributes; + private KeyValuePair attribute1; + private KeyValuePair attribute2; + private KeyValuePair attribute3; + private KeyValuePair attribute4; + private KeyValuePair attribute5; + private KeyValuePair attribute6; + private KeyValuePair attribute7; + private KeyValuePair attribute8; + private int count; + + /// + public readonly int Count => this.count; + + /// + public KeyValuePair this[int index] + { + readonly get + { + if (this.OverflowAttributes is not null) + { + Debug.Assert(index < this.OverflowAttributes.Count, "Invalid index accessed."); + return this.OverflowAttributes[index]; + } + + if ((uint)index >= (uint)this.count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + return index switch + { + 0 => this.attribute1, + 1 => this.attribute2, + 2 => this.attribute3, + 3 => this.attribute4, + 4 => this.attribute5, + 5 => this.attribute6, + 6 => this.attribute7, + 7 => this.attribute8, + _ => default, // we shouldn't come here anyway. + }; + } + + set + { + if (this.OverflowAttributes is not null) + { + Debug.Assert(index < this.OverflowAttributes.Count, "Invalid index accessed."); + this.OverflowAttributes[index] = value; + return; + } + + if ((uint)index >= (uint)this.count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + + switch (index) + { + case 0: this.attribute1 = value; break; + case 1: this.attribute2 = value; break; + case 2: this.attribute3 = value; break; + case 3: this.attribute4 = value; break; + case 4: this.attribute5 = value; break; + case 5: this.attribute6 = value; break; + case 6: this.attribute7 = value; break; + case 7: this.attribute8 = value; break; + default: + Debug.Assert(false, "Unreachable code executed."); + break; + } + } + } + + /// + /// Add an attribute. + /// + /// Attribute name. + /// Attribute value. + [EditorBrowsable(EditorBrowsableState.Never)] + public object? this[string key] + { + // Note: This only exists to enable collection initializer syntax + // like { ["key"] = value }. + set => this.Add(new KeyValuePair(key, value)); + } + + /// + /// Create a collection from an enumerable. + /// + /// Source attributes. + /// . + public static LogRecordAttributeList CreateFromEnumerable(IEnumerable> attributes) + { + Guard.ThrowIfNull(attributes); + + LogRecordAttributeList logRecordAttributes = default; + logRecordAttributes.OverflowAttributes = new(attributes); + logRecordAttributes.count = logRecordAttributes.OverflowAttributes.Count; + return logRecordAttributes; + } + + /// + /// Add an attribute. + /// + /// Attribute name. + /// Attribute value. + public void Add(string key, object? value) + => this.Add(new KeyValuePair(key, value)); + + /// + /// Add an attribute. + /// + /// Attribute. + public void Add(KeyValuePair attribute) + { + if (this.OverflowAttributes is not null) + { + this.OverflowAttributes.Add(attribute); + this.count++; + return; + } + + Debug.Assert(this.count <= 8, "Item added beyond struct capacity."); + + switch (this.count) + { + case 0: this.attribute1 = attribute; break; + case 1: this.attribute2 = attribute; break; + case 2: this.attribute3 = attribute; break; + case 3: this.attribute4 = attribute; break; + case 4: this.attribute5 = attribute; break; + case 5: this.attribute6 = attribute; break; + case 6: this.attribute7 = attribute; break; + case 7: this.attribute8 = attribute; break; + case 8: + Debug.Assert(this.OverflowAttributes is null, "Overflow attributes already created."); + this.MoveAttributesToTheOverflowList(); + Debug.Assert(this.OverflowAttributes is not null, "Overflow attributes creation failure."); + this.OverflowAttributes!.Add(attribute); + break; + default: + // We shouldn't come here. + Debug.Assert(this.OverflowAttributes is null, "Unreachable code executed."); + return; + } + + this.count++; + } + + /// + /// Returns an enumerator that iterates through the . + /// + /// . + public readonly Enumerator GetEnumerator() + => new(in this); + + /// + readonly IEnumerator> IEnumerable>.GetEnumerator() => this.GetEnumerator(); + + /// + readonly IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + internal readonly void ApplyToLogRecord(LogRecord logRecord) + { + int count = this.count; + if (count <= 0) + { + logRecord.StateValues = null; + return; + } + + var overflowAttributes = this.OverflowAttributes; + if (overflowAttributes != null) + { + // An allocation has already occurred, just use the buffer. + logRecord.StateValues = overflowAttributes; + return; + } + + Debug.Assert(count <= 8, "Invalid size detected."); + + var attributeStorage = logRecord.AttributeStorage ??= new List>(OverflowAdditionalCapacity); + + try + { + // TODO: Perf test this, adjust as needed. + + attributeStorage.Add(this.attribute1); + if (count == 1) + { + return; + } + + attributeStorage.Add(this.attribute2); + if (count == 2) + { + return; + } + + attributeStorage.Add(this.attribute3); + if (count == 3) + { + return; + } + + attributeStorage.Add(this.attribute4); + if (count == 4) + { + return; + } + + attributeStorage.Add(this.attribute5); + if (count == 5) + { + return; + } + + attributeStorage.Add(this.attribute6); + if (count == 6) + { + return; + } + + attributeStorage.Add(this.attribute7); + if (count == 7) + { + return; + } + + attributeStorage.Add(this.attribute8); + } + finally + { + logRecord.StateValues = attributeStorage; + } + } + + private void MoveAttributesToTheOverflowList() + { + this.OverflowAttributes = new(16) + { + { this.attribute1 }, + { this.attribute2 }, + { this.attribute3 }, + { this.attribute4 }, + { this.attribute5 }, + { this.attribute6 }, + { this.attribute7 }, + { this.attribute8 }, + }; + } + + /// + /// Enumerates the elements of a . + /// + public struct Enumerator : IEnumerator>, IEnumerator + { + private LogRecordAttributeList attributes; + private int index; + + internal Enumerator(in LogRecordAttributeList attributes) + { + this.index = -1; + this.attributes = attributes; + } + + /// + public readonly KeyValuePair Current + => this.attributes[this.index]; + + /// + readonly object IEnumerator.Current => this.Current; + + /// + public bool MoveNext() + { + this.index++; + return this.index < this.attributes.Count; + } + + /// + public readonly void Dispose() + { + } + + /// + readonly void IEnumerator.Reset() + => throw new NotSupportedException(); + } + } +} diff --git a/src/OpenTelemetry/Logs/LogRecordData.cs b/src/OpenTelemetry/Logs/LogRecordData.cs index c2411c74d52..538bf0a3666 100644 --- a/src/OpenTelemetry/Logs/LogRecordData.cs +++ b/src/OpenTelemetry/Logs/LogRecordData.cs @@ -29,6 +29,14 @@ internal struct LogRecordData { internal DateTime TimestampBacking = DateTime.UtcNow; + /// + /// Initializes a new instance of the struct. + /// + public LogRecordData() + : this(activity: null) + { + } + /// /// Initializes a new instance of the struct. /// @@ -37,7 +45,7 @@ internal struct LogRecordData /// cref="DateTime.UtcNow"/> automatically. /// /// Optional used to populate context fields. - public LogRecordData(Activity? activity = null) + public LogRecordData(Activity? activity) { if (activity != null) { diff --git a/src/OpenTelemetry/Logs/OpenTelemetryLogger.cs b/src/OpenTelemetry/Logs/OpenTelemetryLogger.cs index 88b799434dd..91698d1123a 100644 --- a/src/OpenTelemetry/Logs/OpenTelemetryLogger.cs +++ b/src/OpenTelemetry/Logs/OpenTelemetryLogger.cs @@ -92,6 +92,13 @@ public bool IsEnabled(LogLevel logLevel) private static IReadOnlyList> ParseState(LogRecord logRecord, TState state) { + /* TODO: Enable this if/when LogRecordAttributeList becomes public. + if (state is LogRecordAttributeList logRecordAttributes) + { + logRecordAttributes.ApplyToLogRecord(logRecord); + return logRecord.AttributeStorage!; + } + else*/ if (state is IReadOnlyList> stateList) { return stateList; diff --git a/src/OpenTelemetry/Logs/OpenTelemetryLoggerProvider.cs b/src/OpenTelemetry/Logs/OpenTelemetryLoggerProvider.cs index 221dbd11651..d94769b7f59 100644 --- a/src/OpenTelemetry/Logs/OpenTelemetryLoggerProvider.cs +++ b/src/OpenTelemetry/Logs/OpenTelemetryLoggerProvider.cs @@ -157,6 +157,12 @@ public bool ForceFlush(int timeoutMilliseconds = Timeout.Infinite) return this.Processor?.ForceFlush(timeoutMilliseconds) ?? true; } + /// + /// Create a . + /// + /// . + internal LogEmitter CreateEmitter() => new(this); + internal OpenTelemetryLoggerProvider AddProcessor(BaseProcessor processor) { Guard.ThrowIfNull(processor); diff --git a/test/OpenTelemetry.Tests/Logs/LogEmitterTests.cs b/test/OpenTelemetry.Tests/Logs/LogEmitterTests.cs new file mode 100644 index 00000000000..7b2b649dba5 --- /dev/null +++ b/test/OpenTelemetry.Tests/Logs/LogEmitterTests.cs @@ -0,0 +1,177 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace OpenTelemetry.Logs.Tests +{ + public sealed class LogEmitterTests + { + [Fact] + public void LogEmitterBasicTest() + { + var exportedItems = new List(); + + using var provider = new OpenTelemetryLoggerProvider(options => + { + options.AddInMemoryExporter(exportedItems); + }); + + var logEmitter = provider.CreateEmitter(); + + Exception ex = new InvalidOperationException(); + + logEmitter.Log( + new() + { + CategoryName = "LogEmitter", + Message = "Hello world", + LogLevel = LogLevel.Warning, + EventId = new EventId(18, "CustomEvent"), + Exception = ex, + }, + new() + { + ["key1"] = "value1", + ["key2"] = "value2", + }); + + Assert.Single(exportedItems); + + var logRecord = exportedItems[0]; + + Assert.NotNull(logRecord); + Assert.Equal("LogEmitter", logRecord.CategoryName); + Assert.Equal("Hello world", logRecord.FormattedMessage); + Assert.Equal(LogLevel.Warning, logRecord.LogLevel); + Assert.Equal(18, logRecord.EventId.Id); + Assert.Equal("CustomEvent", logRecord.EventId.Name); + Assert.Equal(ex, logRecord.Exception); + Assert.NotEqual(DateTime.MinValue, logRecord.Timestamp); + + Assert.Equal(default, logRecord.TraceId); + Assert.Equal(default, logRecord.SpanId); + Assert.Equal(ActivityTraceFlags.None, logRecord.TraceFlags); + Assert.Null(logRecord.TraceState); + + Assert.NotNull(logRecord.StateValues); + Assert.Equal(2, logRecord.StateValues.Count); + Assert.Contains(logRecord.StateValues, item => item.Key == "key1" && (string)item.Value == "value1"); + Assert.Contains(logRecord.StateValues, item => item.Key == "key2" && (string)item.Value == "value2"); + } + + [Fact] + public void LogEmitterFromActivityTest() + { + var exportedItems = new List(); + + using var provider = new OpenTelemetryLoggerProvider(options => + { + options.AddInMemoryExporter(exportedItems); + }); + + var logEmitter = provider.CreateEmitter(); + + using var activity = new Activity("Test"); + + activity.Start(); + + activity.ActivityTraceFlags = ActivityTraceFlags.Recorded; + activity.TraceStateString = "key1=value1"; + + logEmitter.Log(new(activity)); + + Assert.Single(exportedItems); + + var logRecord = exportedItems[0]; + + Assert.NotNull(logRecord); + + Assert.Equal(activity.TraceId, logRecord.TraceId); + Assert.Equal(activity.SpanId, logRecord.SpanId); + Assert.Equal(activity.ActivityTraceFlags, logRecord.TraceFlags); + Assert.Equal(activity.TraceStateString, logRecord.TraceState); + + Assert.Null(logRecord.StateValues); + } + + [Fact] + public void LogEmitterLocalToUtcTimestampTest() + { + var exportedItems = new List(); + + using var provider = new OpenTelemetryLoggerProvider(options => + { + options.AddInMemoryExporter(exportedItems); + }); + + var logEmitter = provider.CreateEmitter(); + + DateTime timestamp = DateTime.SpecifyKind( + new DateTime(2022, 6, 30, 16, 0, 0), + DateTimeKind.Local); + + logEmitter.Log(new() + { + Timestamp = timestamp, + }); + + Assert.Single(exportedItems); + + var logRecord = exportedItems[0]; + + Assert.NotNull(logRecord); + + Assert.Equal(timestamp.ToUniversalTime(), logRecord.Timestamp); + Assert.Equal(DateTimeKind.Utc, logRecord.Timestamp.Kind); + } + + [Fact] + public void LogEmitterUnspecifiedTimestampTest() + { + var exportedItems = new List(); + + using var provider = new OpenTelemetryLoggerProvider(options => + { + options.AddInMemoryExporter(exportedItems); + }); + + var logEmitter = provider.CreateEmitter(); + + DateTime timestamp = DateTime.SpecifyKind( + new DateTime(2022, 6, 30, 16, 0, 0), + DateTimeKind.Unspecified); + + logEmitter.Log(new() + { + Timestamp = timestamp, + }); + + Assert.Single(exportedItems); + + var logRecord = exportedItems[0]; + + Assert.NotNull(logRecord); + + Assert.Equal(timestamp, logRecord.Timestamp); + Assert.Equal(DateTimeKind.Unspecified, logRecord.Timestamp.Kind); + } + } +} diff --git a/test/OpenTelemetry.Tests/Logs/LogRecordAttributeListTests.cs b/test/OpenTelemetry.Tests/Logs/LogRecordAttributeListTests.cs new file mode 100644 index 00000000000..d0e068c46b1 --- /dev/null +++ b/test/OpenTelemetry.Tests/Logs/LogRecordAttributeListTests.cs @@ -0,0 +1,103 @@ +// +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using Xunit; + +namespace OpenTelemetry.Logs.Tests +{ + public sealed class LogRecordAttributeListTests + { + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(8)] + [InlineData(9)] + [InlineData(64)] + public void ReadWriteTest(int numberOfItems) + { + LogRecordAttributeList attributes = default; + + for (int i = 0; i < numberOfItems; i++) + { + attributes.Add($"key{i}", i); + } + + Assert.Equal(numberOfItems, attributes.Count); + + for (int i = 0; i < numberOfItems; i++) + { + var item = attributes[i]; + + Assert.Equal($"key{i}", item.Key); + Assert.Equal(i, (int)item.Value); + } + + int index = 0; + foreach (KeyValuePair item in attributes) + { + Assert.Equal($"key{index}", item.Key); + Assert.Equal(index, (int)item.Value); + index++; + } + + if (attributes.Count <= LogRecordAttributeList.OverflowAdditionalCapacity) + { + Assert.Null(attributes.OverflowAttributes); + } + else + { + Assert.NotNull(attributes.OverflowAttributes); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(8)] + [InlineData(9)] + [InlineData(64)] + public void ApplyToLogRecordTest(int numberOfItems) + { + LogRecordAttributeList attributes = default; + + for (int i = 0; i < numberOfItems; i++) + { + attributes.Add($"key{i}", i); + } + + LogRecord logRecord = new(); + + attributes.ApplyToLogRecord(logRecord); + + if (numberOfItems == 0) + { + Assert.Null(logRecord.StateValues); + return; + } + + Assert.NotNull(logRecord.StateValues); + + int index = 0; + foreach (KeyValuePair item in logRecord.StateValues) + { + Assert.Equal($"key{index}", item.Key); + Assert.Equal(index, (int)item.Value); + index++; + } + } + } +}