File: src\Shared\ConsoleLogs\SharedAIHelpers.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.csproj (aspire)
// 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;
using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
 
namespace Aspire.Shared.ConsoleLogs;
 
/// <summary>
/// Shared AI helper methods for console log processing.
/// Used by both Dashboard and CLI.
/// </summary>
internal static class SharedAIHelpers
{
    public const int TracesLimit = 200;
    public const int StructuredLogsLimit = 200;
    public const int ConsoleLogsLimit = 500;
    public const int MaximumListTokenLength = 8192;
    public const int MaximumStringLength = 2048;
 
    private static readonly JsonSerializerOptions s_jsonSerializerOptions = new JsonSerializerOptions
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };
 
    /// <summary>
    /// Estimates the token count for a string.
    /// This is a rough estimate - use a library for exact calculation.
    /// </summary>
    public static int EstimateTokenCount(string text)
    {
        return text.Length / 4;
    }
 
    /// <summary>
    /// Estimates the serialized JSON token size for a JsonNode.
    /// </summary>
    public static int EstimateSerializedJsonTokenSize(JsonNode node)
    {
        var json = node.ToJsonString(s_jsonSerializerOptions);
        return EstimateTokenCount(json);
    }
 
    /// <summary>
    /// Converts OTLP resource logs to structured logs JSON for AI processing.
    /// </summary>
    /// <param name="resourceLogs">The OTLP resource logs containing log records.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>A tuple containing the JSON string and a limit message.</returns>
    public static (string json, string limitMessage) GetStructuredLogsJson(
        IList<OtlpResourceLogsJson>? resourceLogs,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var logRecords = GetLogRecordsFromOtlpData(resourceLogs);
        var promptContext = new PromptContext();
 
        var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary(
            logRecords,
            StructuredLogsLimit,
            "log entry",
            "log entries",
            i => GetLogEntryDto(i, promptContext, getResourceName, dashboardBaseUrl),
            EstimateSerializedJsonTokenSize);
 
        var jsonArray = new JsonArray(trimmedItems.ToArray());
        var logsData = jsonArray.ToJsonString(s_jsonSerializerOptions);
 
        return (logsData, limitMessage);
    }
 
    /// <summary>
    /// Converts OTLP resource logs to a single structured log JSON for AI processing.
    /// </summary>
    /// <param name="resourceLogs">The OTLP resource logs containing log records.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>The JSON string for the first log entry.</returns>
    public static string GetStructuredLogJson(
        IList<OtlpResourceLogsJson>? resourceLogs,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var logRecords = GetLogRecordsFromOtlpData(resourceLogs);
        var logEntry = logRecords.FirstOrDefault() ?? throw new InvalidOperationException("No log entry found in OTLP data.");
        var promptContext = new PromptContext();
        var dto = GetLogEntryDto(logEntry, promptContext, getResourceName, dashboardBaseUrl);
 
        return dto.ToJsonString(s_jsonSerializerOptions);
    }
 
    /// <summary>
    /// Converts OTLP resource spans to traces JSON for AI processing.
    /// </summary>
    /// <param name="resourceSpans">The OTLP resource spans containing trace data.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>A tuple containing the JSON string and a limit message.</returns>
    public static (string json, string limitMessage) GetTracesJson(
        IList<OtlpResourceSpansJson>? resourceSpans,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var traces = GetTracesFromOtlpData(resourceSpans);
        var promptContext = new PromptContext();
 
        var (trimmedItems, limitMessage) = GetLimitFromEndWithSummary(
            traces,
            TracesLimit,
            "trace",
            "traces",
            t => GetTraceDto(t, promptContext, getResourceName, dashboardBaseUrl),
            EstimateSerializedJsonTokenSize);
 
        var jsonArray = new JsonArray(trimmedItems.ToArray());
        var tracesData = jsonArray.ToJsonString(s_jsonSerializerOptions);
 
        return (tracesData, limitMessage);
    }
 
    /// <summary>
    /// Converts OTLP resource spans to a single trace JSON for AI processing.
    /// </summary>
    /// <param name="resourceSpans">The OTLP resource spans containing trace data.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>The JSON string for the first trace.</returns>
    public static string GetTraceJson(
        IList<OtlpResourceSpansJson>? resourceSpans,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var traces = GetTracesFromOtlpData(resourceSpans);
        var trace = traces.FirstOrDefault() ?? throw new InvalidOperationException("No trace found in OTLP data.");
        var promptContext = new PromptContext();
        var dto = GetTraceDto(trace, promptContext, getResourceName, dashboardBaseUrl);
 
        return dto.ToJsonString(s_jsonSerializerOptions);
    }
 
    /// <summary>
    /// Extracts traces from OTLP resource spans, grouping spans by trace ID.
    /// </summary>
    public static List<OtlpTraceDto> GetTracesFromOtlpData(IList<OtlpResourceSpansJson>? resourceSpans)
    {
        var spansByTraceId = new Dictionary<string, List<OtlpSpanDto>>(StringComparer.Ordinal);
 
        if (resourceSpans is null)
        {
            return [];
        }
 
        foreach (var resourceSpan in resourceSpans)
        {
            var resource = CreateResourceFromOtlpJson(resourceSpan.Resource);
 
            if (resourceSpan.ScopeSpans is null)
            {
                continue;
            }
 
            foreach (var scopeSpan in resourceSpan.ScopeSpans)
            {
                var scopeName = scopeSpan.Scope?.Name;
 
                if (scopeSpan.Spans is null)
                {
                    continue;
                }
 
                foreach (var span in scopeSpan.Spans)
                {
                    var traceId = span.TraceId ?? string.Empty;
                    if (!spansByTraceId.TryGetValue(traceId, out var spanList))
                    {
                        spanList = [];
                        spansByTraceId[traceId] = spanList;
                    }
 
                    spanList.Add(new OtlpSpanDto(span, resource, scopeName));
                }
            }
        }
 
        return spansByTraceId
            .Select(kvp => new OtlpTraceDto(kvp.Key, kvp.Value))
            .ToList();
    }
 
    /// <summary>
    /// Creates a JsonObject representing a trace for AI processing.
    /// </summary>
    /// <param name="trace">The trace DTO to convert.</param>
    /// <param name="context">The prompt context for tracking duplicate values.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>A JsonObject containing the trace data.</returns>
    public static JsonObject GetTraceDto(
        OtlpTraceDto trace,
        PromptContext context,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var spanObjects = new List<JsonNode>();
        foreach (var s in trace.Spans)
        {
            var span = s.Span;
            var spanId = span.SpanId ?? string.Empty;
 
            var attributesObj = new JsonObject();
            if (span.Attributes is not null)
            {
                foreach (var attr in span.Attributes.Where(a => a.Key != OtlpHelpers.AspireDestinationNameAttribute))
                {
                    var attrValue = MapOtelAttributeValue(attr);
                    attributesObj[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for span {OtlpHelpers.ToShortenedId(id.SpanId)}", (SpanId: spanId, attr.Key));
                }
            }
 
            JsonArray? linksArray = null;
            if (span.Links is { Length: > 0 })
            {
                var linkObjects = span.Links.Select(link => (JsonNode)new JsonObject
                {
                    ["trace_id"] = OtlpHelpers.ToShortenedId(link.TraceId ?? string.Empty),
                    ["span_id"] = OtlpHelpers.ToShortenedId(link.SpanId ?? string.Empty)
                }).ToArray();
                linksArray = new JsonArray(linkObjects);
            }
 
            var resourceName = getResourceName?.Invoke(s.Resource) ?? s.Resource.ResourceName;
            var destination = GetAttributeStringValue(span.Attributes, OtlpHelpers.AspireDestinationNameAttribute);
            var statusCode = span.Status?.Code;
            var statusText = statusCode switch
            {
                1 => "Ok",
                2 => "Error",
                _ => null
            };
 
            var spanObj = new JsonObject
            {
                ["span_id"] = OtlpHelpers.ToShortenedId(spanId),
                ["parent_span_id"] = span.ParentSpanId is { } id ? OtlpHelpers.ToShortenedId(id) : null,
                ["kind"] = GetSpanKindName(span.Kind),
                ["name"] = context.AddValue(span.Name, sId => $@"Duplicate of ""name"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId),
                ["status"] = statusText,
                ["status_message"] = context.AddValue(span.Status?.Message, sId => $@"Duplicate of ""status_message"" for span {OtlpHelpers.ToShortenedId(sId)}", spanId),
                ["source"] = resourceName,
                ["destination"] = destination,
                ["duration_ms"] = CalculateDurationMs(span.StartTimeUnixNano, span.EndTimeUnixNano),
                ["attributes"] = attributesObj,
                ["links"] = linksArray
            };
            spanObjects.Add(spanObj);
        }
 
        var spanArray = new JsonArray(spanObjects.ToArray());
        var traceId = OtlpHelpers.ToShortenedId(trace.TraceId);
        var rootSpan = trace.Spans.FirstOrDefault(s => string.IsNullOrEmpty(s.Span.ParentSpanId)) ?? trace.Spans.FirstOrDefault();
        var hasError = trace.Spans.Any(s => s.Span.Status?.Code == 2);
        var timestamp = rootSpan?.Span.StartTimeUnixNano is { } startNano
            ? OtlpHelpers.UnixNanoSecondsToDateTime(startNano)
            : (DateTime?)null;
 
        var traceData = new JsonObject
        {
            ["trace_id"] = traceId,
            ["duration_ms"] = CalculateTraceDurationMs(trace.Spans),
            ["title"] = rootSpan?.Span.Name,
            ["spans"] = spanArray,
            ["has_error"] = hasError,
            ["timestamp"] = timestamp
        };
 
        if (dashboardBaseUrl is not null)
        {
            traceData["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.TraceDetailUrl(traceId), traceId);
        }
 
        return traceData;
    }
 
    private static string MapOtelAttributeValue(OtlpKeyValueJson attribute)
    {
        var key = attribute.Key;
        var value = GetAttributeValue(attribute);
 
        switch (key)
        {
            case "http.response.status_code":
                {
                    if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue))
                    {
                        return GetHttpStatusName(intValue);
                    }
                    goto default;
                }
            case "rpc.grpc.status_code":
                {
                    if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue))
                    {
                        return GetGrpcStatusName(intValue);
                    }
                    goto default;
                }
            default:
                return value;
        }
    }
 
    private static string GetHttpStatusName(int statusCode)
    {
        return statusCode switch
        {
            200 => "200 OK",
            201 => "201 Created",
            204 => "204 No Content",
            301 => "301 Moved Permanently",
            302 => "302 Found",
            304 => "304 Not Modified",
            400 => "400 Bad Request",
            401 => "401 Unauthorized",
            403 => "403 Forbidden",
            404 => "404 Not Found",
            405 => "405 Method Not Allowed",
            408 => "408 Request Timeout",
            409 => "409 Conflict",
            422 => "422 Unprocessable Entity",
            429 => "429 Too Many Requests",
            500 => "500 Internal Server Error",
            501 => "501 Not Implemented",
            502 => "502 Bad Gateway",
            503 => "503 Service Unavailable",
            504 => "504 Gateway Timeout",
            _ => statusCode.ToString(CultureInfo.InvariantCulture)
        };
    }
 
    private static string GetGrpcStatusName(int statusCode)
    {
        return statusCode switch
        {
            0 => "OK",
            1 => "CANCELLED",
            2 => "UNKNOWN",
            3 => "INVALID_ARGUMENT",
            4 => "DEADLINE_EXCEEDED",
            5 => "NOT_FOUND",
            6 => "ALREADY_EXISTS",
            7 => "PERMISSION_DENIED",
            8 => "RESOURCE_EXHAUSTED",
            9 => "FAILED_PRECONDITION",
            10 => "ABORTED",
            11 => "OUT_OF_RANGE",
            12 => "UNIMPLEMENTED",
            13 => "INTERNAL",
            14 => "UNAVAILABLE",
            15 => "DATA_LOSS",
            16 => "UNAUTHENTICATED",
            _ => statusCode.ToString(CultureInfo.InvariantCulture)
        };
    }
 
    private static string? GetSpanKindName(int? kind)
    {
        return kind switch
        {
            1 => "Internal",
            2 => "Server",
            3 => "Client",
            4 => "Producer",
            5 => "Consumer",
            _ => null
        };
    }
 
    private static int? CalculateDurationMs(ulong? startTimeUnixNano, ulong? endTimeUnixNano)
    {
        if (startTimeUnixNano is null || endTimeUnixNano is null)
        {
            return null;
        }
 
        var durationNano = endTimeUnixNano.Value - startTimeUnixNano.Value;
        return (int)Math.Round(durationNano / 1_000_000.0, 0, MidpointRounding.AwayFromZero);
    }
 
    private static int? CalculateTraceDurationMs(List<OtlpSpanDto> spans)
    {
        if (spans.Count == 0)
        {
            return null;
        }
 
        ulong? minStart = null;
        ulong? maxEnd = null;
 
        foreach (var s in spans)
        {
            if (s.Span.StartTimeUnixNano is { } start)
            {
                minStart = minStart is null ? start : Math.Min(minStart.Value, start);
            }
            if (s.Span.EndTimeUnixNano is { } end)
            {
                maxEnd = maxEnd is null ? end : Math.Max(maxEnd.Value, end);
            }
        }
 
        return CalculateDurationMs(minStart, maxEnd);
    }
 
    /// <summary>
    /// Extracts log records from OTLP resource logs.
    /// </summary>
    public static List<OtlpLogEntryDto> GetLogRecordsFromOtlpData(IList<OtlpResourceLogsJson>? resourceLogs)
    {
        var logRecords = new List<OtlpLogEntryDto>();
 
        if (resourceLogs is null)
        {
            return logRecords;
        }
 
        foreach (var resourceLog in resourceLogs)
        {
            var resource = CreateResourceFromOtlpJson(resourceLog.Resource);
 
            if (resourceLog.ScopeLogs is null)
            {
                continue;
            }
 
            foreach (var scopeLogs in resourceLog.ScopeLogs)
            {
                var scopeName = scopeLogs.Scope?.Name;
 
                if (scopeLogs.LogRecords is null)
                {
                    continue;
                }
 
                foreach (var logRecord in scopeLogs.LogRecords)
                {
                    logRecords.Add(new OtlpLogEntryDto(logRecord, resource, scopeName));
                }
            }
        }
 
        return logRecords;
    }
 
    /// <summary>
    /// Gets the message from a log record.
    /// </summary>
    public static string? GetLogMessage(OtlpLogRecordJson logRecord)
    {
        return logRecord.Body?.StringValue;
    }
 
    /// <summary>
    /// Gets the attribute value as a string.
    /// </summary>
    public static string GetAttributeValue(OtlpKeyValueJson attribute)
    {
        if (attribute.Value is null)
        {
            return string.Empty;
        }
 
        return attribute.Value.StringValue
            ?? attribute.Value.IntValue?.ToString(CultureInfo.InvariantCulture)
            ?? attribute.Value.DoubleValue?.ToString(CultureInfo.InvariantCulture)
            ?? attribute.Value.BoolValue?.ToString(CultureInfo.InvariantCulture)
            ?? string.Empty;
    }
 
    /// <summary>
    /// Gets the value of an attribute by key as a string, or null if not found.
    /// </summary>
    public static string? GetAttributeStringValue(OtlpKeyValueJson[]? attributes, string key)
    {
        if (attributes is null)
        {
            return null;
        }
 
        foreach (var attr in attributes)
        {
            if (attr.Key == key)
            {
                var value = GetAttributeValue(attr);
                return string.IsNullOrEmpty(value) ? null : value;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Creates a SimpleOtlpResource from OTLP resource JSON.
    /// </summary>
    /// <param name="resource">The OTLP resource JSON, or null.</param>
    /// <returns>A SimpleOtlpResource with the service name and instance ID extracted from attributes.</returns>
    private static SimpleOtlpResource CreateResourceFromOtlpJson(OtlpResourceJson? resource)
    {
        var serviceName = GetAttributeStringValue(resource?.Attributes, "service.name");
        var serviceInstanceId = GetAttributeStringValue(resource?.Attributes, "service.instance.id");
        var resourceName = serviceName ?? "Unknown";
        return new SimpleOtlpResource(resourceName, serviceInstanceId);
    }
 
    private const string ExceptionStackTraceField = "exception.stacktrace";
    private const string ExceptionMessageField = "exception.message";
    private const string ExceptionTypeField = "exception.type";
 
    /// <summary>
    /// Filters out exception-related attributes and internal Aspire attributes from the attributes list.
    /// </summary>
    public static IEnumerable<OtlpKeyValueJson> GetFilteredAttributes(OtlpKeyValueJson[]? attributes)
    {
        if (attributes is null)
        {
            return [];
        }
 
        return attributes.Where(a => a.Key is not (ExceptionStackTraceField or ExceptionMessageField or ExceptionTypeField or OtlpHelpers.AspireLogIdAttribute));
    }
 
    /// <summary>
    /// Gets the exception text from a log entry's attributes.
    /// </summary>
    public static string? GetExceptionText(OtlpLogEntryDto logEntry)
    {
        var stackTrace = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionStackTraceField);
        if (!string.IsNullOrEmpty(stackTrace))
        {
            return stackTrace;
        }
 
        var message = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionMessageField);
        if (!string.IsNullOrEmpty(message))
        {
            var type = GetAttributeStringValue(logEntry.LogRecord.Attributes, ExceptionTypeField);
            if (!string.IsNullOrEmpty(type))
            {
                return $"{type}: {message}";
            }
 
            return message;
        }
 
        return null;
    }
 
    /// <summary>
    /// Creates a JsonObject representing a log entry for AI processing.
    /// </summary>
    /// <param name="logEntry">The log entry to convert.</param>
    /// <param name="context">The prompt context for tracking duplicate values.</param>
    /// <param name="getResourceName">Optional function to resolve resource names.</param>
    /// <param name="dashboardBaseUrl">Optional dashboard URL.</param>
    /// <returns>A JsonObject containing the log entry data.</returns>
    public static JsonObject GetLogEntryDto(
        OtlpLogEntryDto logEntry,
        PromptContext context,
        Func<IOtlpResource, string> getResourceName,
        string? dashboardBaseUrl = null)
    {
        var exceptionText = GetExceptionText(logEntry);
        var logIdString = GetAttributeStringValue(logEntry.LogRecord.Attributes, OtlpHelpers.AspireLogIdAttribute);
        var logId = long.TryParse(logIdString, CultureInfo.InvariantCulture, out var parsedLogId) ? parsedLogId : (long?)null;
        var resourceName = getResourceName?.Invoke(logEntry.Resource) ?? logEntry.Resource.ResourceName;
 
        var attributesObject = new JsonObject();
        foreach (var attr in GetFilteredAttributes(logEntry.LogRecord.Attributes))
        {
            var attrValue = GetAttributeValue(attr);
            attributesObject[attr.Key!] = context.AddValue(attrValue, id => $@"Duplicate of attribute ""{id.Key}"" for log entry {id.LogId}", (LogId: logId, attr.Key));
        }
 
        var message = GetLogMessage(logEntry.LogRecord) ?? string.Empty;
        var log = new JsonObject
        {
            ["log_id"] = logId,
            ["span_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.SpanId ?? string.Empty),
            ["trace_id"] = OtlpHelpers.ToShortenedId(logEntry.LogRecord.TraceId ?? string.Empty),
            ["message"] = context.AddValue(message, id => $@"Duplicate of ""message"" for log entry {id}", logId),
            ["severity"] = logEntry.LogRecord.SeverityText ?? "Unknown",
            ["resource_name"] = resourceName,
            ["attributes"] = attributesObject,
            ["exception"] = context.AddValue(exceptionText, id => $@"Duplicate of ""exception"" for log entry {id}", logId),
            ["source"] = logEntry.ScopeName
        };
 
        if (dashboardBaseUrl is not null && logId is not null)
        {
            log["dashboard_link"] = GetDashboardLinkObject(dashboardBaseUrl, DashboardUrls.StructuredLogsUrl(logEntryId: logId), $"log_id: {logId}");
        }
 
        return log;
    }
 
    public static JsonObject? GetDashboardLinkObject(string dashboardBaseUrl, string path, string text)
    {
        return new JsonObject
        {
            ["url"] = DashboardUrls.CombineUrl(dashboardBaseUrl, path),
            ["text"] = text
        };
    }
 
    /// <summary>
    /// Serializes a log entry to a string, stripping timestamps and ANSI control sequences.
    /// </summary>
    public static string SerializeLogEntry(LogEntry logEntry)
    {
        if (logEntry.RawContent is not null)
        {
            var content = logEntry.RawContent;
            if (TimestampParser.TryParseConsoleTimestamp(content, out var timestampParseResult))
            {
                content = timestampParseResult.Value.ModifiedText;
            }
 
            return LimitLength(AnsiParser.StripControlSequences(content));
        }
        else
        {
            return string.Empty;
        }
    }
 
    /// <summary>
    /// Serializes a list of log entry strings to a single string with newlines.
    /// </summary>
    public static string SerializeConsoleLogs(IList<string> logEntries)
    {
        var consoleLogsText = new StringBuilder();
 
        foreach (var logEntry in logEntries)
        {
            consoleLogsText.AppendLine(logEntry);
        }
 
        return consoleLogsText.ToString();
    }
 
    /// <summary>
    /// Limits a string to the maximum length, appending a truncation marker if needed.
    /// </summary>
    public static string LimitLength(string value)
    {
        if (value.Length <= MaximumStringLength)
        {
            return value;
        }
 
        return
            $"""
            {value.AsSpan(0, MaximumStringLength)}...[TRUNCATED]
            """;
    }
 
    /// <summary>
    /// Gets items from the end of a list with a summary message, applying count and token limits.
    /// </summary>
    public static (List<TResult> items, string message) GetLimitFromEndWithSummary<T, TResult>(
        List<T> values,
        int limit,
        string itemName,
        string pluralItemName,
        Func<T, TResult> convertToDto,
        Func<TResult, int> estimateTokenSize)
    {
        return GetLimitFromEndWithSummary(values, values.Count, limit, itemName, pluralItemName, convertToDto, estimateTokenSize);
    }
 
    /// <summary>
    /// Gets items from the end of a list with a summary message, applying count and token limits.
    /// </summary>
    public static (List<TResult> items, string message) GetLimitFromEndWithSummary<T, TResult>(
        List<T> values,
        int totalValues,
        int limit,
        string itemName,
        string pluralItemName,
        Func<T, TResult> convertToDto,
        Func<TResult, int> estimateTokenSize)
    {
        Debug.Assert(totalValues >= values.Count, "Total values should be large or equal to the values passed into the method.");
 
        var trimmedItems = values.Count <= limit
            ? values
            : values[^limit..];
 
        var currentTokenCount = 0;
        var serializedValuesCount = 0;
        var dtos = trimmedItems.Select(i => convertToDto(i)).ToList();
 
        // Loop backwards to prioritize the latest items.
        for (var i = dtos.Count - 1; i >= 0; i--)
        {
            var obj = dtos[i];
            var tokenCount = estimateTokenSize(obj);
 
            if (currentTokenCount + tokenCount > MaximumListTokenLength)
            {
                break;
            }
 
            serializedValuesCount++;
            currentTokenCount += tokenCount;
        }
 
        // Trim again with what fits in the token limit.
        dtos = dtos[^serializedValuesCount..];
 
        return (dtos, GetLimitSummary(totalValues, dtos.Count, itemName, pluralItemName));
    }
 
    /// <summary>
    /// Gets a summary message describing how many items were returned vs total.
    /// </summary>
    public static string GetLimitSummary(int totalValues, int returnedCount, string itemName, string pluralItemName)
    {
        if (totalValues == returnedCount)
        {
            return $"Returned {ToQuantity(returnedCount, itemName, pluralItemName)}.";
        }
 
        return $"Returned latest {ToQuantity(returnedCount, itemName, pluralItemName)}. Earlier {ToQuantity(totalValues - returnedCount, itemName, pluralItemName)} not returned because of size limits.";
    }
 
    /// <summary>
    /// Formats an item name with quantity (e.g., "1 console log" or "5 console logs").
    /// </summary>
    private static string ToQuantity(int count, string itemName, string pluralItemName)
    {
        var name = count == 1 ? itemName : pluralItemName;
        return string.Create(CultureInfo.InvariantCulture, $"{count} {name}");
    }
}
 
/// <summary>
/// Represents a log entry extracted from OTLP JSON format for AI processing.
/// </summary>
/// <param name="LogRecord">The OTLP log record JSON data.</param>
/// <param name="Resource">The resource information from the resource attributes.</param>
/// <param name="ScopeName">The instrumentation scope name.</param>
internal sealed record OtlpLogEntryDto(OtlpLogRecordJson LogRecord, IOtlpResource Resource, string? ScopeName);
 
/// <summary>
/// Represents a trace (collection of spans with the same trace ID) extracted from OTLP JSON format.
/// </summary>
/// <param name="TraceId">The trace ID.</param>
/// <param name="Spans">The spans belonging to this trace.</param>
internal sealed record OtlpTraceDto(string TraceId, List<OtlpSpanDto> Spans);
 
/// <summary>
/// Represents a span extracted from OTLP JSON format for AI processing.
/// </summary>
/// <param name="Span">The OTLP span JSON data.</param>
/// <param name="Resource">The resource information from the resource attributes.</param>
/// <param name="ScopeName">The instrumentation scope name.</param>
internal sealed record OtlpSpanDto(OtlpSpanJson Span, IOtlpResource Resource, string? ScopeName);