diff --git a/src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md b/src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md index 95d9c0e3fe..098f363cdd 100644 --- a/src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md +++ b/src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +* PassThru TableNameMappings using the logger category name. +[345](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/345) + * Throw exception when `TableNameMappings` contains a `null` value. [322](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/322) diff --git a/src/OpenTelemetry.Exporter.Geneva/GenevaBaseExporter.cs b/src/OpenTelemetry.Exporter.Geneva/GenevaBaseExporter.cs index 7132e31a62..8cc50613a3 100644 --- a/src/OpenTelemetry.Exporter.Geneva/GenevaBaseExporter.cs +++ b/src/OpenTelemetry.Exporter.Geneva/GenevaBaseExporter.cs @@ -14,6 +14,7 @@ // limitations under the License. // +using System; using System.Collections.Generic; namespace OpenTelemetry.Exporter.Geneva; @@ -89,4 +90,19 @@ internal static int AddPartAField(byte[] buffer, int cursor, string name, object cursor = MessagePackSerializer.Serialize(buffer, cursor, value); return cursor; } + + internal static int AddPartAField(byte[] buffer, int cursor, string name, Span value) + { + if (V40_PART_A_MAPPING.TryGetValue(name, out string replacementKey)) + { + cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, replacementKey); + } + else + { + cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, name); + } + + cursor = MessagePackSerializer.SerializeSpan(buffer, cursor, value); + return cursor; + } } diff --git a/src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs b/src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs index 331ec1dda4..f700622aca 100644 --- a/src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs +++ b/src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs @@ -31,6 +31,7 @@ namespace OpenTelemetry.Exporter.Geneva; public class GenevaLogExporter : GenevaBaseExporter { private const int BUFFER_SIZE = 65360; // the maximum ETW payload (inclusive) + private const int MaxSanitizedEventNameLength = 50; private readonly IReadOnlyDictionary m_customFields; private readonly string m_defaultEventName = "Log"; @@ -44,6 +45,7 @@ public class GenevaLogExporter : GenevaBaseExporter }; private readonly IDataTransport m_dataTransport; + private readonly bool shouldPassThruTableMappings; private bool isDisposed; private Func convertToJson; @@ -65,7 +67,14 @@ public GenevaLogExporter(GenevaExporterOptions options) if (kv.Key == "*") { - this.m_defaultEventName = kv.Value; + if (kv.Value == "*") + { + this.shouldPassThruTableMappings = true; + } + else + { + this.m_defaultEventName = kv.Value; + } } else { @@ -204,14 +213,6 @@ internal int SerializeLogRecord(LogRecord logRecord) listKvp = logRecord.State as IReadOnlyList>; } - var name = logRecord.CategoryName; - - // If user configured explicit TableName, use it. - if (this.m_tableMappings == null || !this.m_tableMappings.TryGetValue(name, out var eventName)) - { - eventName = this.m_defaultEventName; - } - var buffer = m_buffer.Value; if (buffer == null) { @@ -242,7 +243,43 @@ internal int SerializeLogRecord(LogRecord logRecord) var timestamp = logRecord.Timestamp; var cursor = 0; cursor = MessagePackSerializer.WriteArrayHeader(buffer, cursor, 3); - cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, eventName); + + var categoryName = logRecord.CategoryName; + string eventName = null; + + Span sanitizedEventName = default; + + // If user configured explicit TableName, use it. + if (this.m_tableMappings != null && this.m_tableMappings.TryGetValue(categoryName, out eventName)) + { + cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, eventName); + } + else if (!this.shouldPassThruTableMappings) + { + eventName = this.m_defaultEventName; + cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, eventName); + } + else + { + int cursorStartIdx = cursor; + + if (categoryName.Length > 0) + { + cursor = SerializeSanitizedCategoryName(buffer, cursor, categoryName); + } + + if (cursor == cursorStartIdx) + { + // Serializing null as categoryName could not be sanitized into a valid string. + cursor = MessagePackSerializer.SerializeNull(buffer, cursor); + } + else + { + // Sanitized category name has been serialized. + sanitizedEventName = buffer.AsSpan().Slice(cursorStartIdx, cursor - cursorStartIdx); + } + } + cursor = MessagePackSerializer.WriteArrayHeader(buffer, cursor, 1); cursor = MessagePackSerializer.WriteArrayHeader(buffer, cursor, 2); cursor = MessagePackSerializer.SerializeUtcDateTime(buffer, cursor, timestamp); @@ -282,7 +319,15 @@ internal int SerializeLogRecord(LogRecord logRecord) } // Part A - core envelope - cursor = AddPartAField(buffer, cursor, Schema.V40.PartA.Name, eventName); + if (sanitizedEventName.Length != 0) + { + cursor = AddPartAField(buffer, cursor, Schema.V40.PartA.Name, sanitizedEventName); + } + else + { + cursor = AddPartAField(buffer, cursor, Schema.V40.PartA.Name, eventName); + } + cntFields += 1; cursor = AddPartAField(buffer, cursor, Schema.V40.PartA.Time, timestamp); @@ -329,7 +374,7 @@ internal int SerializeLogRecord(LogRecord logRecord) cntFields += 1; cursor = MessagePackSerializer.SerializeAsciiString(buffer, cursor, "name"); - cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, name); + cursor = MessagePackSerializer.SerializeUnicodeString(buffer, cursor, categoryName); cntFields += 1; bool hasEnvProperties = false; @@ -443,5 +488,60 @@ private static byte GetSeverityNumber(LogLevel logLevel) return 1; } } + + // This method would map the logger category to a table name which only contains alphanumeric values with the following additions: + // Any character that is not allowed will be removed. + // If the resulting string is longer than 50 characters, only the first 50 characters will be taken. + // If the first character in the resulting string is a lower-case alphabet, it will be converted to the corresponding upper-case. + // If the resulting string still does not comply with Rule, the category name will not be serialized. + private static int SerializeSanitizedCategoryName(byte[] buffer, int cursor, string categoryName) + { + int cursorStartIdx = cursor; + + // Reserve 2 bytes for storing LIMIT_MAX_STR8_LENGTH_IN_BYTES and (byte)validNameLength - + // these 2 bytes will be back filled after iterating through categoryName. + cursor += 2; + int validNameLength = 0; + + // Special treatment for the first character. + var firstChar = categoryName[0]; + if (firstChar >= 'A' && firstChar <= 'Z') + { + buffer[cursor++] = (byte)firstChar; + ++validNameLength; + } + else if (firstChar >= 'a' && firstChar <= 'z') + { + // If the first character in the resulting string is a lower-case alphabet, + // it will be converted to the corresponding upper-case. + buffer[cursor++] = (byte)(firstChar - 32); + ++validNameLength; + } + else + { + // Not a valid name. + return cursor -= 2; + } + + for (int i = 1; i < categoryName.Length; ++i) + { + if (validNameLength == MaxSanitizedEventNameLength) + { + break; + } + + var cur = categoryName[i]; + if ((cur >= 'a' && cur <= 'z') || (cur >= 'A' && cur <= 'Z') || (cur >= '0' && cur <= '9')) + { + buffer[cursor++] = (byte)cur; + ++validNameLength; + } + } + + // Backfilling MessagePack serialization protocol and valid category length to the startIdx of the categoryName byte array. + MessagePackSerializer.WriteStr8Header(buffer, cursorStartIdx, validNameLength); + + return cursor; + } } #endif diff --git a/src/OpenTelemetry.Exporter.Geneva/MessagePackSerializer.cs b/src/OpenTelemetry.Exporter.Geneva/MessagePackSerializer.cs index 396ae9d582..331ad0e36d 100644 --- a/src/OpenTelemetry.Exporter.Geneva/MessagePackSerializer.cs +++ b/src/OpenTelemetry.Exporter.Geneva/MessagePackSerializer.cs @@ -306,6 +306,13 @@ private static unsafe long Float64ToInt64(double value) return *(long*)&value; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteStr8Header(byte[] buffer, int nameStartIdx, int validNameLength) + { + buffer[nameStartIdx] = STR8; + buffer[nameStartIdx + 1] = unchecked((byte)validNameLength); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int SerializeAsciiString(byte[] buffer, int cursor, string value) { @@ -571,4 +578,14 @@ public static int Serialize(byte[] buffer, int cursor, object obj) return SerializeUnicodeString(buffer, cursor, repr); } } + + public static int SerializeSpan(byte[] buffer, int cursor, Span value) + { + for (int i = 0; i < value.Length; ++i) + { + buffer[cursor++] = value[i]; + } + + return cursor; + } } diff --git a/test/OpenTelemetry.Exporter.Geneva.Tests/GenevaLogExporterTests.cs b/test/OpenTelemetry.Exporter.Geneva.Tests/GenevaLogExporterTests.cs index 276320ec9c..7edab1856f 100644 --- a/test/OpenTelemetry.Exporter.Geneva.Tests/GenevaLogExporterTests.cs +++ b/test/OpenTelemetry.Exporter.Geneva.Tests/GenevaLogExporterTests.cs @@ -228,6 +228,101 @@ public void TableNameMappingTest(params string[] category) } } + [Fact] + [Trait("Platform", "Any")] + public void PassThruTableMappingsWhenTheRuleIsEnabled() + { + var userInitializedCategoryToTableNameMappings = new Dictionary + { + ["Company.Store"] = "Store", + ["Company.Orders"] = "Orders", + ["*"] = "*", + }; + + var expectedCategoryToTableNameList = new List> + { + // The category name must match "^[A-Z][a-zA-Z0-9]*$"; any character that is not allowed will be removed. + new KeyValuePair("Company.Customer", "CompanyCustomer"), + + new KeyValuePair("Company-%-Customer*Region$##", "CompanyCustomerRegion"), + + // If the first character in the resulting string is lower-case ALPHA, + // it will be converted to the corresponding upper-case. + new KeyValuePair("company.Calendar", "CompanyCalendar"), + + // After removing not allowed characters, + // if the resulting string is still an illegal event name, the data will get dropped on the floor. + new KeyValuePair("$&-.$~!!", null), + + new KeyValuePair("dlmwl3bvd84bxsx8wf700nx9rydrrhfewbxf82ceoo0h8rpla4", "Dlmwl3bvd84bxsx8wf700nx9rydrrhfewbxf82ceoo0h8rpla4"), + + // If the resulting string is longer than 50 characters, only the first 50 characters will be taken. + new KeyValuePair("Company.Customer.rsLiheLClHJasBOvM.XI4uW7iop6ghvwBzahfs", "CompanyCustomerrsLiheLClHJasBOvMXI4uW7iop6ghvwBzah"), + + // The data will be dropped on the floor as the exporter cannot deduce a valid table name. + new KeyValuePair("1.2", null), + }; + + var logRecordList = new List(); + var exporterOptions = new GenevaExporterOptions + { + TableNameMappings = userInitializedCategoryToTableNameMappings, + ConnectionString = "EtwSession=OpenTelemetry", + }; + + using var loggerFactory = LoggerFactory.Create(builder => builder + .AddOpenTelemetry(options => + { + options.AddInMemoryExporter(logRecordList); + }) + .AddFilter("*", LogLevel.Trace)); // Enable all LogLevels + + // Create a test exporter to get MessagePack byte data to validate if the data was serialized correctly. + using var exporter = new GenevaLogExporter(exporterOptions); + + ILogger passThruTableMappingsLogger, userInitializedTableMappingsLogger; + ThreadLocal m_buffer; + object fluentdData; + string actualTableName; + m_buffer = typeof(GenevaLogExporter).GetField("m_buffer", BindingFlags.NonPublic | BindingFlags.Static).GetValue(exporter) as ThreadLocal; + + // Verify that the category table mappings specified by the users in the Geneva Configuration are mapped correctly. + foreach (var mapping in userInitializedCategoryToTableNameMappings) + { + if (mapping.Key != "*") + { + userInitializedTableMappingsLogger = loggerFactory.CreateLogger(mapping.Key); + userInitializedTableMappingsLogger.LogInformation("This information does not matter."); + Assert.Single(logRecordList); + + _ = exporter.SerializeLogRecord(logRecordList[0]); + fluentdData = MessagePack.MessagePackSerializer.Deserialize(m_buffer.Value, MessagePack.Resolvers.ContractlessStandardResolver.Instance); + actualTableName = (fluentdData as object[])[0] as string; + userInitializedCategoryToTableNameMappings.TryGetValue(mapping.Key, out var expectedTableNme); + Assert.Equal(expectedTableNme, actualTableName); + + logRecordList.Clear(); + } + } + + // Verify that when the "*" = "*" were enabled, the correct table names were being deduced following the set of rules. + foreach (var mapping in expectedCategoryToTableNameList) + { + passThruTableMappingsLogger = loggerFactory.CreateLogger(mapping.Key); + passThruTableMappingsLogger.LogInformation("This information does not matter."); + Assert.Single(logRecordList); + + _ = exporter.SerializeLogRecord(logRecordList[0]); + fluentdData = MessagePack.MessagePackSerializer.Deserialize(m_buffer.Value, MessagePack.Resolvers.ContractlessStandardResolver.Instance); + actualTableName = (fluentdData as object[])[0] as string; + string expectedTableName = string.Empty; + expectedTableName = mapping.Value; + Assert.Equal(expectedTableName, actualTableName); + + logRecordList.Clear(); + } + } + [Theory] [InlineData(true)] [InlineData(false)]