File: Otlp\Model\OtlpHelpers.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Dashboard.Otlp.Storage;
using Google.Protobuf;
using Google.Protobuf.Collections;
using OpenTelemetry.Proto.Common.V1;
using OpenTelemetry.Proto.Resource.V1;
 
namespace Aspire.Dashboard.Otlp.Model;
 
public static class OtlpHelpers
{
    // Reduce size of JSON data by not indenting. Dashboard UI supports formatting JSON values when they're displayed.
    private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions
    {
        WriteIndented = false
    };
 
    public static ApplicationKey GetApplicationKey(this Resource resource)
    {
        string? serviceName = null;
        string? serviceInstanceId = null;
        string? processExecutableName = null;
 
        for (var i = 0; i < resource.Attributes.Count; i++)
        {
            var attribute = resource.Attributes[i];
            if (attribute.Key == OtlpApplication.SERVICE_INSTANCE_ID)
            {
                serviceInstanceId = attribute.Value.GetString();
            }
            if (attribute.Key == OtlpApplication.SERVICE_NAME)
            {
                serviceName = attribute.Value.GetString();
            }
            if (attribute.Key == OtlpApplication.PROCESS_EXECUTABLE_NAME)
            {
                processExecutableName = attribute.Value.GetString();
            }
        }
 
        // Fallback to unknown_service if service name isn't specified.
        // https://github.com/open-telemetry/opentelemetry-specification/issues/3210
        if (string.IsNullOrEmpty(serviceName))
        {
            serviceName = "unknown_service";
            if (!string.IsNullOrEmpty(processExecutableName))
            {
                serviceName += ":" + processExecutableName;
            }
        }
 
        // service.instance.id is recommended but not required.
        return new ApplicationKey(serviceName, serviceInstanceId ?? serviceName);
    }
 
    public static string ToShortenedId(string id) => TruncateString(id, maxLength: 7);
 
    public static string ToHexString(ReadOnlyMemory<byte> bytes)
    {
        if (bytes.Length == 0)
        {
            return string.Empty;
        }
 
        // This produces lowercase hex string from the bytes. It's used instead of Convert.ToHexString()
        // because we want to display lowercase hex string in the UI for values such as traceid and spanid.
        return string.Create(bytes.Length * 2, bytes, static (chars, bytes) =>
        {
            var data = bytes.Span;
            for (var pos = 0; pos < data.Length; pos++)
            {
                ToCharsBuffer(data[pos], chars, pos * 2);
            }
        });
    }
 
    public static string TruncateString(string value, int maxLength)
    {
        return value.Length > maxLength ? value[..maxLength] : value;
    }
 
    public static string ToHexString(this ByteString bytes)
    {
        return ToHexString(bytes.Memory);
    }
 
    public static string GetString(this AnyValue value) =>
        value.ValueCase switch
        {
            AnyValue.ValueOneofCase.StringValue => value.StringValue,
            AnyValue.ValueOneofCase.IntValue => value.IntValue.ToString(CultureInfo.InvariantCulture),
            AnyValue.ValueOneofCase.DoubleValue => value.DoubleValue.ToString(CultureInfo.InvariantCulture),
            AnyValue.ValueOneofCase.BoolValue => value.BoolValue ? "true" : "false",
            AnyValue.ValueOneofCase.BytesValue => value.BytesValue.ToHexString(),
            AnyValue.ValueOneofCase.ArrayValue => ConvertAnyValue(value)!.ToJsonString(s_jsonSerializerOptions),
            AnyValue.ValueOneofCase.KvlistValue => ConvertAnyValue(value)!.ToJsonString(s_jsonSerializerOptions),
            AnyValue.ValueOneofCase.None => string.Empty,
            _ => value.ToString(),
        };
 
    private static JsonNode? ConvertAnyValue(AnyValue value)
    {
        // Recursively convert AnyValue types to JsonNode types to produce more idiomatic JSON.
        // Recursing over incoming values is safe because Protobuf serializer imposes a safe limit on recursive messages.
        return value.ValueCase switch
        {
            AnyValue.ValueOneofCase.StringValue => JsonValue.Create(value.StringValue),
            AnyValue.ValueOneofCase.IntValue => JsonValue.Create(value.IntValue),
            AnyValue.ValueOneofCase.DoubleValue => JsonValue.Create(value.DoubleValue),
            AnyValue.ValueOneofCase.BoolValue => JsonValue.Create(value.BoolValue),
            AnyValue.ValueOneofCase.BytesValue => JsonValue.Create(value.BytesValue.ToHexString()),
            AnyValue.ValueOneofCase.ArrayValue => ConvertArray(value.ArrayValue),
            AnyValue.ValueOneofCase.KvlistValue => ConvertKeyValues(value.KvlistValue),
            AnyValue.ValueOneofCase.None => null,
            _ => throw new InvalidOperationException($"Unexpected AnyValue type: {value.ValueCase}"),
        };
 
        static JsonArray ConvertArray(ArrayValue value)
        {
            var a = new JsonArray();
            foreach (var item in value.Values)
            {
                a.Add(ConvertAnyValue(item));
            }
            return a;
        }
 
        static JsonObject ConvertKeyValues(KeyValueList value)
        {
            var o = new JsonObject();
            foreach (var item in value.Values)
            {
                o[item.Key] = ConvertAnyValue(item.Value);
            }
            return o;
        }
    }
 
    // From https://github.com/dotnet/runtime/blob/963954a11c1beeea4ad63002084a213d8d742864/src/libraries/Common/src/System/HexConverter.cs#L81-L89
    // Modified slightly to always produce lowercase output.
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static void ToCharsBuffer(byte value, Span<char> buffer, int startingIndex = 0)
    {
        var difference = ((value & 0xF0U) << 4) + (value & 0x0FU) - 0x8989U;
        var packedResult = (((uint)-(int)difference & 0x7070U) >> 4) + difference + 0xB9B9U | 0x2020U;
 
        buffer[startingIndex + 1] = (char)(packedResult & 0xFF);
        buffer[startingIndex] = (char)(packedResult >> 8);
    }
 
    public static DateTime UnixNanoSecondsToDateTime(ulong unixTimeNanoseconds)
    {
        var ticks = NanosecondsToTicks(unixTimeNanoseconds);
 
        return DateTime.UnixEpoch.AddTicks(ticks);
    }
 
    private static long NanosecondsToTicks(ulong nanoseconds)
    {
        return (long)(nanoseconds / TimeSpan.NanosecondsPerTick);
    }
 
    public static KeyValuePair<string, string>[] ToKeyValuePairs(this RepeatedField<KeyValue> attributes, OtlpContext context)
    {
        if (attributes.Count == 0)
        {
            return Array.Empty<KeyValuePair<string, string>>();
        }
 
        var values = new KeyValuePair<string, string>[Math.Min(attributes.Count, context.Options.MaxAttributeCount)];
        CopyKeyValues(attributes, values, index: 0, context, out var copyCount);
 
        if (values.Length == copyCount)
        {
            return values;
        }
        else
        {
            return values[..copyCount];
        }
    }
 
    public static KeyValuePair<string, string>[] ToKeyValuePairs(this RepeatedField<KeyValue> attributes, OtlpContext context, Func<KeyValue, bool> filter)
    {
        if (attributes.Count == 0)
        {
            return Array.Empty<KeyValuePair<string, string>>();
        }
 
        var readLimit = Math.Min(attributes.Count, context.Options.MaxAttributeCount);
        List<KeyValuePair<string, string>>? values = null;
        for (var i = 0; i < attributes.Count; i++)
        {
            var attribute = attributes[i];
 
            if (!filter(attribute))
            {
                continue;
            }
 
            values ??= new List<KeyValuePair<string, string>>(readLimit);
 
            var value = TruncateString(attribute.Value.GetString(), context.Options.MaxAttributeLength);
 
            // If there are duplicates then last value wins.
            var existingIndex = GetIndex(values, attribute.Key);
            if (existingIndex >= 0)
            {
                var existingAttribute = values[existingIndex];
                if (existingAttribute.Value != value)
                {
                    context.Logger.LogDebug("Duplicate attribute {Name} with different value. Last value wins.", attribute.Key);
                    values[existingIndex] = new KeyValuePair<string, string>(attribute.Key, value);
                }
            }
            else
            {
                if (values.Count < readLimit)
                {
                    values.Add(new KeyValuePair<string, string>(attribute.Key, value));
                }
            }
        }
 
        return values?.ToArray() ?? [];
 
        static int GetIndex(List<KeyValuePair<string, string>> values, string name)
        {
            for (var i = 0; i < values.Count; i++)
            {
                if (values[i].Key == name)
                {
                    return i;
                }
            }
            return -1;
        }
    }
 
    public static void CopyKeyValuePairs(RepeatedField<KeyValue> attributes, KeyValuePair<string, string>[] parentAttributes, OtlpContext context, out int copyCount, [NotNull] ref KeyValuePair<string, string>[]? copiedAttributes)
    {
        copyCount = Math.Min(parentAttributes.Length + attributes.Count, context.Options.MaxAttributeCount);
 
        // Attribute limit already reached.
        if (copyCount == parentAttributes.Length)
        {
            copiedAttributes = parentAttributes;
            return;
        }
 
        if (copiedAttributes is null || copiedAttributes.Length < copyCount)
        {
            // Existing array isn't big enough. Create new bigger array.
            copiedAttributes = new KeyValuePair<string, string>[copyCount];
        }
        else
        {
            // Clear existing array before reuse.
            Array.Clear(copiedAttributes);
        }
 
        parentAttributes.AsSpan().CopyTo(copiedAttributes);
 
        CopyKeyValues(attributes, copiedAttributes, parentAttributes.Length, context, out var newCopyCount);
        copyCount = parentAttributes.Length + newCopyCount;
    }
 
    private static void CopyKeyValues(RepeatedField<KeyValue> attributes, KeyValuePair<string, string>[] copiedAttributes, int index, OtlpContext context, out int copyCount)
    {
        var desiredCopyCount = Math.Min(attributes.Count + index, context.Options.MaxAttributeCount);
        desiredCopyCount -= index;
 
        // Don't immediately break out of loop when the limit is reached. The rules for attributes is last value wins.
        // That means we want to loop through all attributes to check for new values to overwrite old.
        copyCount = 0;
        for (var i = 0; i < attributes.Count; i++)
        {
            var attribute = attributes[i];
 
            var value = TruncateString(attribute.Value.GetString(), context.Options.MaxAttributeLength);
 
            // If there are duplicates then last value wins.
            var existingIndex = GetIndex(copiedAttributes, attribute.Key);
            if (existingIndex >= 0)
            {
                var existingAttribute = copiedAttributes[existingIndex];
                if (existingAttribute.Value != value)
                {
                    context.Logger.LogDebug("Duplicate attribute {Name} with different value. Last value wins.", attribute.Key);
                    copiedAttributes[existingIndex] = new KeyValuePair<string, string>(attribute.Key, value);
                }
            }
            else
            {
                if (copyCount < desiredCopyCount)
                {
                    copiedAttributes[index + copyCount] = new KeyValuePair<string, string>(attribute.Key, value);
                    copyCount++;
                }
            }
        }
    }
 
    public static string? GetValue(this KeyValuePair<string, string>[] values, string name)
    {
        var i = values.GetIndex(name);
        if (i >= 0)
        {
            return values[i].Value;
        }
 
        return null;
    }
 
    public static int GetIndex(this KeyValuePair<string, string>[] values, string name)
    {
        for (var i = 0; i < values.Length; i++)
        {
            if (values[i].Key == name)
            {
                return i;
            }
        }
        return -1;
    }
 
    public static string? GetPeerAddress(this KeyValuePair<string, string>[] values)
    {
        var address = GetValue(values, OtlpSpan.PeerServiceAttributeKey);
        if (address != null)
        {
            return address;
        }
 
        // OTEL HTTP 1.7.0 doesn't return peer.service. Fallback to server.address and server.port.
        if (GetValue(values, OtlpSpan.ServerAddressAttributeKey) is { } server)
        {
            if (GetValue(values, OtlpSpan.ServerPortAttributeKey) is { } serverPort)
            {
                server += ":" + serverPort;
            }
            return server;
        }
 
        // Fallback to older names of net.peer.name and net.peer.port.
        if (GetValue(values, OtlpSpan.NetPeerNameAttributeKey) is { } peer)
        {
            if (GetValue(values, OtlpSpan.NetPeerPortAttributeKey) is { } peerPort)
            {
                peer += ":" + peerPort;
            }
            return peer;
        }
 
        return null;
    }
 
    public static bool HasKey(this KeyValuePair<string, string>[] values, string name)
    {
        for (var i = 0; i < values.Length; i++)
        {
            if (values[i].Key == name)
            {
                return true;
            }
        }
        return false;
    }
 
    public static string ConcatProperties(this KeyValuePair<string, string>[] properties)
    {
        StringBuilder sb = new();
        var first = true;
        foreach (var kv in properties)
        {
            if (!first)
            {
                sb.Append(", ");
            }
            first = false;
            sb.Append(CultureInfo.InvariantCulture, $"{kv.Key}: ");
            sb.Append(string.IsNullOrEmpty(kv.Value) ? "\'\'" : kv.Value);
        }
        return sb.ToString();
    }
 
    public static PagedResult<T> GetItems<T>(IEnumerable<T> results, int startIndex, int count)
    {
        return GetItems<T, T>(results, startIndex, count, null);
    }
 
    public static PagedResult<TResult> GetItems<TSource, TResult>(IEnumerable<TSource> results, int startIndex, int count, Func<TSource, TResult>? select)
    {
        var query = results.Skip(startIndex).Take(count);
        List<TResult> items;
        if (select != null)
        {
            items = query.Select(select).ToList();
        }
        else
        {
            items = query.Cast<TResult>().ToList();
        }
        var totalItemCount = results.Count();
 
        return new PagedResult<TResult>
        {
            Items = items,
            TotalItemCount = totalItemCount
        };
    }
}