Skip to content

Commit

Permalink
[Geneva.Logs] Support prefix-based table name mapping (#796)
Browse files Browse the repository at this point in the history
* Added support for prefix based table mappings in geneva log exporter.

* Benchmark tweak.

* Patch CHANGELOG.

* README updates.

* Lint.

* Code review.

* Code review.

* Test updates.

* Code review.

* Code review.

* Code review.

* Code review.

* Warning cleanup.
  • Loading branch information
CodeBlanch authored Dec 16, 2022
1 parent 97f1882 commit 3768220
Show file tree
Hide file tree
Showing 12 changed files with 616 additions and 152 deletions.
3 changes: 3 additions & 0 deletions src/OpenTelemetry.Exporter.Geneva/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Released 2022-Dec-09
* Fix EventSource logging
([#813](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/813))

* Add support in logs for prefix-based table name mapping configuration.
[#796](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/796)

## 1.4.0-beta.5

Released 2022-Nov-21
Expand Down
22 changes: 20 additions & 2 deletions src/OpenTelemetry.Exporter.Geneva/GenevaExporterOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,32 @@ public IReadOnlyDictionary<string, string> TableNameMappings

foreach (var entry in value)
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
throw new ArgumentException("A table name mapping key was null, empty, or consisted only of white-space characters.", nameof(this.TableNameMappings));
}

if (string.IsNullOrWhiteSpace(entry.Value))
{
throw new ArgumentException($"A string-typed value provided for {nameof(this.TableNameMappings)} must not be null, empty, or consist only of white-space characters.");
throw new ArgumentException($"The table name mapping value provided for key '{entry.Key}' was null, empty, or consisted only of white-space characters.", nameof(this.TableNameMappings));
}

if (Encoding.UTF8.GetByteCount(entry.Value) != entry.Value.Length)
{
throw new ArgumentException($"A string-typed value provided for {nameof(this.TableNameMappings)} must not contain non-ASCII characters.", entry.Value);
throw new ArgumentException($"The table name mapping value '{entry.Value}' provided for key '{entry.Key}' contained non-ASCII characters.", nameof(this.TableNameMappings));
}

if (entry.Value != "*")
{
if (!TableNameSerializer.IsValidTableName(entry.Value))
{
throw new ArgumentException($"The table name mapping value '{entry.Value}' provided for key '{entry.Key}' contained invalid characters or was too long.", nameof(this.TableNameMappings));
}

if (TableNameSerializer.IsReservedTableName(entry.Value))
{
throw new ArgumentException($"The table name mapping value '{entry.Value}' provided for key '{entry.Key}' is reserved and cannot be specified.", nameof(this.TableNameMappings));
}
}

copy[entry.Key] = entry.Value;
Expand Down
2 changes: 1 addition & 1 deletion src/OpenTelemetry.Exporter.Geneva/GenevaLogExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public GenevaLogExporter(GenevaExporterOptions options)

public override ExportResult Export(in Batch<LogRecord> batch)
{
return this.exportLogRecord(batch);
return this.exportLogRecord(in batch);
}

protected override void Dispose(bool disposing)
Expand Down
2 changes: 1 addition & 1 deletion src/OpenTelemetry.Exporter.Geneva/GenevaTraceExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public GenevaTraceExporter(GenevaExporterOptions options)

public override ExportResult Export(in Batch<Activity> batch)
{
return this.exportActivity(batch);
return this.exportActivity(in batch);
}

protected override void Dispose(bool disposing)
Expand Down
328 changes: 328 additions & 0 deletions src/OpenTelemetry.Exporter.Geneva/Internal/TableNameSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
// <copyright file="TableNameSerializer.cs" company="OpenTelemetry Authors">
// 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.
// </copyright>

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;

namespace OpenTelemetry.Exporter.Geneva;

internal sealed class TableNameSerializer
{
public const int MaxSanitizedCategoryNameLength = 50;
public const int MaxSanitizedCategoryNameBytes = MaxSanitizedCategoryNameLength + 2;
public const int MaxCachedSanitizedTableNames = 1024;
private const StringComparison DictionaryKeyComparison = StringComparison.Ordinal;

#pragma warning disable CA1825 // Avoid zero-length array allocations
/* Note: We don't use Array.Empty<byte> here because that is used to
indicate an invalid name. We need a different instance to trigger the
pass-through case. */
private static readonly byte[] s_passthroughTableName = new byte[0];
#pragma warning restore CA1825 // Avoid zero-length array allocations
private static readonly StringComparer s_dictionaryKeyComparer = StringComparer.Ordinal;

private readonly byte[] m_defaultTableName;
private readonly Dictionary<string, byte[]> m_tableMappings;
private readonly bool m_shouldPassThruTableMappings;
private readonly object m_lockObject = new();
private TableNameCacheDictionary m_tableNameCache = new();

public ITableNameCacheDictionary TableNameCache => this.m_tableNameCache;

public TableNameSerializer(GenevaExporterOptions options, string defaultTableName)
{
Debug.Assert(options != null, "options were null");
Debug.Assert(!string.IsNullOrWhiteSpace(defaultTableName), "defaultEventName was null or whitespace");
Debug.Assert(IsValidTableName(defaultTableName), "defaultEventName was invalid");

this.m_defaultTableName = BuildStr8BufferForAsciiString(defaultTableName);

if (options.TableNameMappings != null)
{
var tempTableMappings = new Dictionary<string, byte[]>(options.TableNameMappings.Count, s_dictionaryKeyComparer);
foreach (var kv in options.TableNameMappings)
{
if (kv.Key == "*")
{
if (kv.Value == "*")
{
this.m_shouldPassThruTableMappings = true;
}
else
{
this.m_defaultTableName = BuildStr8BufferForAsciiString(kv.Value);
}
}
else if (kv.Value == "*")
{
tempTableMappings[kv.Key] = s_passthroughTableName;
}
else
{
tempTableMappings[kv.Key] = BuildStr8BufferForAsciiString(kv.Value);
}
}

this.m_tableMappings = tempTableMappings;
}
}

public static bool IsReservedTableName(string tableName)
{
Debug.Assert(!string.IsNullOrWhiteSpace(tableName), "tableName was null or whitespace");

// TODO: Implement this if needed.

return false;
}

public static bool IsValidTableName(string tableName)
{
Debug.Assert(!string.IsNullOrWhiteSpace(tableName), "tableName was null or whitespace");

var length = tableName.Length;
if (length > MaxSanitizedCategoryNameLength)
{
return false;
}

char firstChar = tableName[0];
if (firstChar < 'A' || firstChar > 'Z')
{
return false;
}

for (int i = 1; i < length; i++)
{
char cur = tableName[i];
if ((cur >= 'a' && cur <= 'z') || (cur >= 'A' && cur <= 'Z') || (cur >= '0' && cur <= '9'))
{
continue;
}

return false;
}

return true;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ResolveAndSerializeTableNameForCategoryName(byte[] destination, int offset, string categoryName, out ReadOnlySpan<byte> tableName)
{
byte[] mappedTableName = this.ResolveTableMappingForCategoryName(categoryName);

if (mappedTableName == s_passthroughTableName)
{
// Pass-through mode with a full cache.

int bytesWritten = WriteSanitizedCategoryNameToSpan(new Span<byte>(destination, offset, MaxSanitizedCategoryNameBytes), categoryName);

tableName = new ReadOnlySpan<byte>(destination, offset, bytesWritten);

return offset + bytesWritten;
}

tableName = mappedTableName;

return MessagePackSerializer.SerializeSpan(destination, offset, tableName);
}

private static byte[] BuildStr8BufferForAsciiString(string value)
{
var length = value.Length;

byte[] buffer = new byte[length + 2];

Encoding.ASCII.GetBytes(value, 0, length, buffer, 2);

MessagePackSerializer.WriteStr8Header(buffer, 0, length);

return buffer;
}

// 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 WriteSanitizedCategoryNameToSpan(Span<byte> buffer, string categoryName)
{
// 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.
int 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 0;
}

for (int i = 1; i < categoryName.Length; ++i)
{
if (validNameLength == MaxSanitizedCategoryNameLength)
{
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, 0, validNameLength);

return cursor;
}

private byte[] ResolveTableMappingForCategoryName(string categoryName)
{
var tableNameCache = this.m_tableNameCache;

if (tableNameCache.TryGetValue(categoryName, out byte[] tableName))
{
return tableName;
}

return this.ResolveTableMappingForCategoryNameRare(categoryName);
}

private byte[] ResolveTableMappingForCategoryNameRare(string categoryName)
{
byte[] mappedTableName = null;

// If user configured table name mappings run resolution logic.
if (this.m_tableMappings != null
&& !this.m_tableMappings.TryGetValue(categoryName, out mappedTableName))
{
// Find best match if an exact match was not found.

string currentKey = null;

foreach (var mapping in this.m_tableMappings)
{
if (!categoryName.StartsWith(mapping.Key, DictionaryKeyComparison))
{
continue;
}

if (currentKey == null || mapping.Key.Length >= currentKey.Length)
{
currentKey = mapping.Key;
mappedTableName = mapping.Value;
}
}
}

mappedTableName ??= !this.m_shouldPassThruTableMappings
? this.m_defaultTableName
: s_passthroughTableName;

Span<byte> sanitizedTableNameStorage = mappedTableName == s_passthroughTableName
? stackalloc byte[MaxSanitizedCategoryNameBytes]
: Array.Empty<byte>();

if (sanitizedTableNameStorage.Length > 0)
{
// We resolved to a wildcard which is pass-through mode.

int bytesWritten = WriteSanitizedCategoryNameToSpan(sanitizedTableNameStorage, categoryName);
if (bytesWritten > 0)
{
sanitizedTableNameStorage = sanitizedTableNameStorage.Slice(0, bytesWritten);
}
else
{
// Note: When the table name could not be sanitized we cache
// the empty array NOT s_passthroughTableName.
mappedTableName = Array.Empty<byte>();
}
}

lock (this.m_lockObject)
{
var tableNameCache = this.m_tableNameCache;

// Check if another thread added the mapping while we waited on the
// lock.
if (tableNameCache.TryGetValue(categoryName, out byte[] tableName))
{
return tableName;
}

if (mappedTableName == s_passthroughTableName
&& tableNameCache.CachedSanitizedTableNameCount < MaxCachedSanitizedTableNames)
{
mappedTableName = sanitizedTableNameStorage.ToArray();
tableNameCache.CachedSanitizedTableNameCount++;
}

// Note: This is using copy-on-write pattern to keep the happy
// path lockless once everything has spun up.
TableNameCacheDictionary newTableNameCache = new(tableNameCache)
{
[categoryName] = mappedTableName,
};

this.m_tableNameCache = newTableNameCache;

return mappedTableName;
}
}

// Note: This is used for tests.
public interface ITableNameCacheDictionary : IReadOnlyDictionary<string, byte[]>
{
int CachedSanitizedTableNameCount { get; }
}

private sealed class TableNameCacheDictionary : Dictionary<string, byte[]>, ITableNameCacheDictionary
{
public TableNameCacheDictionary()
: base(0, s_dictionaryKeyComparer)
{
}

public TableNameCacheDictionary(TableNameCacheDictionary sourceCache)
: base(sourceCache, s_dictionaryKeyComparer)
{
this.CachedSanitizedTableNameCount = sourceCache.CachedSanitizedTableNameCount;
}

public int CachedSanitizedTableNameCount { get; set; }
}
}
Loading

0 comments on commit 3768220

Please sign in to comment.