// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.Interaction;
using Aspire.Cli.Mcp.Tools;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Utils;
using Aspire.Otlp.Serialization;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Aspire.Cli.Commands;
/// <summary>
/// Shared helper methods for telemetry commands.
/// </summary>
internal static class TelemetryCommandHelpers
{
/// <summary>
/// HTTP header name for API authentication.
/// </summary>
internal const string ApiKeyHeaderName = "X-API-Key";
/// <summary>
/// Limit passed to dashboard telemetry APIs. All data is fetched in one API call
/// so there shouldn't be a limit on data returned.
/// </summary>
internal const int MaxTelemetryLimit = int.MaxValue;
#region Shared Command Options
/// <summary>
/// Resource name argument shared across telemetry commands.
/// </summary>
internal static Argument<string?> CreateResourceArgument() => new("resource")
{
Description = TelemetryCommandStrings.ResourceArgumentDescription,
Arity = ArgumentArity.ZeroOrOne
};
/// <summary>
/// AppHost option shared across telemetry commands.
/// </summary>
internal static OptionWithLegacy<FileInfo?> CreateAppHostOption() => new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
/// <summary>
/// Output format option shared across telemetry commands.
/// </summary>
internal static Option<OutputFormat> CreateFormatOption() => new("--format")
{
Description = TelemetryCommandStrings.FormatOptionDescription
};
/// <summary>
/// Limit option shared across telemetry commands.
/// </summary>
internal static Option<int?> CreateLimitOption() => new("--limit", "-n")
{
Description = TelemetryCommandStrings.LimitOptionDescription
};
/// <summary>
/// Follow/streaming option for logs and spans commands.
/// </summary>
internal static Option<bool> CreateFollowOption() => new("--follow", "-f")
{
Description = TelemetryCommandStrings.FollowOptionDescription
};
/// <summary>
/// Trace ID filter option shared across telemetry commands.
/// </summary>
internal static Option<string?> CreateTraceIdOption(string name, string? alias = null)
{
var option = alias is null ? new Option<string?>(name) : new Option<string?>(name, alias);
option.Description = TelemetryCommandStrings.TraceIdOptionDescription;
return option;
}
/// <summary>
/// Has error filter option for spans and traces commands.
/// </summary>
internal static Option<bool?> CreateHasErrorOption() => new("--has-error")
{
Description = TelemetryCommandStrings.HasErrorOptionDescription
};
/// <summary>
/// Full-text search option for filtering across all telemetry fields.
/// </summary>
internal static Option<string?> CreateSearchOption() => new("--search")
{
Description = TelemetryCommandStrings.SearchOptionDescription
};
/// <summary>
/// Dashboard URL option for connecting directly to a standalone dashboard.
/// </summary>
internal static Option<string?> CreateDashboardUrlOption() => new("--dashboard-url")
{
Description = TelemetryCommandStrings.DashboardUrlOptionDescription
};
/// <summary>
/// API key option for authenticating with a standalone dashboard.
/// </summary>
internal static Option<string?> CreateApiKeyOption() => new("--api-key")
{
Description = TelemetryCommandStrings.ApiKeyOptionDescription
};
#endregion
/// <summary>
/// Validates that an HTTP response has a JSON content type.
/// </summary>
/// <param name="response">The HTTP response to validate.</param>
/// <returns>True if the response has a JSON content type; false otherwise.</returns>
public static bool HasJsonContentType(HttpResponseMessage response)
{
var mediaType = response.Content.Headers.ContentType?.MediaType;
return mediaType is "application/json" or "text/json" or "application/x-ndjson";
}
/// <summary>
/// Validates a telemetry API response by checking for conditions that indicate the API is not enabled,
/// then ensuring a success status code and JSON content type.
/// </summary>
/// <param name="response">The HTTP response to validate.</param>
/// <remarks>
/// When the dashboard telemetry API is not enabled, requests may return a 404 status code
/// or a 200 with text/html content (Blazor fallback route). In either case, this method throws
/// an <see cref="HttpRequestException"/> with a <see cref="HttpStatusCode.NotFound"/> status code
/// so that existing error handling can detect the condition and display an appropriate message.
/// </remarks>
/// <exception cref="HttpRequestException">
/// Thrown when the response indicates the API is not enabled (404 or HTML content type),
/// when the response has a non-success status code, or when the content type is not JSON.
/// </exception>
public static void EnsureTelemetryApiResponse(HttpResponseMessage response)
{
// A 200 with text/html content type indicates the Blazor fallback route handled the request,
// meaning the telemetry API endpoint doesn't exist. Treat this the same as a 404.
if (response.IsSuccessStatusCode &&
response.Content.Headers.ContentType?.MediaType is "text/html")
{
throw new HttpRequestException(
HttpRequestError.InvalidResponse,
statusCode: HttpStatusCode.NotFound);
}
response.EnsureSuccessStatusCode();
if (!HasJsonContentType(response))
{
var mediaType = response.Content.Headers.ContentType?.MediaType ?? "(none)";
throw new HttpRequestException(
HttpRequestError.InvalidResponse,
string.Format(CultureInfo.InvariantCulture, TelemetryCommandStrings.UnexpectedContentType, mediaType),
inner: null,
response.StatusCode);
}
}
/// <summary>
/// Resolves an AppHost connection and gets Dashboard API info.
/// </summary>
/// <param name="connectionResolver">The connection resolver for AppHost discovery.</param>
/// <param name="interactionService">The interaction service for displaying messages.</param>
/// <param name="httpClientFactory">The HTTP client factory for making API calls.</param>
/// <param name="logger">The logger for diagnostic messages.</param>
/// <param name="projectFile">The optional AppHost project file.</param>
/// <param name="dashboardUrl">The optional direct dashboard URL (mutually exclusive with <paramref name="projectFile"/>).</param>
/// <param name="apiKey">The optional API key for dashboard authentication.</param>
/// <param name="requireDashboard">
/// When <c>true</c>, a missing Dashboard API is a hard error.
/// When <c>false</c>, a missing Dashboard API is non-fatal and the method returns success with <c>null</c> base URL and token.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="DashboardApiResult"/> with the resolved connection and dashboard API info.</returns>
public static async Task<DashboardApiResult> GetDashboardApiAsync(
AppHostConnectionResolver connectionResolver,
IInteractionService interactionService,
IHttpClientFactory httpClientFactory,
ILogger logger,
FileInfo? projectFile,
string? dashboardUrl,
string? apiKey,
bool requireDashboard,
CancellationToken cancellationToken)
{
// Validate mutual exclusivity of --apphost and --dashboard-url
if (projectFile is not null && dashboardUrl is not null)
{
interactionService.DisplayError(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive);
return DashboardApiResult.Failure(CliExitCodes.InvalidCommand);
}
// Direct dashboard URL mode — bypass AppHost discovery
if (dashboardUrl is not null)
{
// Extract login token before normalizing the URL
var loginToken = McpToolHelpers.ExtractLoginToken(dashboardUrl);
// Normalize login URLs (e.g., http://localhost:18888/login?t=abc) to base URL
var displayDashboardUrl = McpToolHelpers.StripLoginPath(dashboardUrl) ?? dashboardUrl;
dashboardUrl = McpToolHelpers.NormalizeDashboardUrl(displayDashboardUrl);
if (!UrlHelper.IsHttpUrl(dashboardUrl))
{
DisplayTelemetryError(
interactionService,
new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlInvalid, dashboardUrl),
TelemetryCommandStrings.DashboardUrlInvalidHint));
return DashboardApiResult.Failure(CliExitCodes.InvalidCommand);
}
// If no explicit --api-key was provided but a login token was found in the URL,
// exchange the login token for an API key via the dashboard.
if (apiKey is null && loginToken is not null)
{
var exchangeResult = await ExchangeLoginTokenForApiKeyAsync(httpClientFactory, dashboardUrl, loginToken, logger, cancellationToken).ConfigureAwait(false);
if (!exchangeResult.Success)
{
var errorInfo = exchangeResult.FailureKind switch
{
TokenExchangeFailureKind.ConnectionError => new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, displayDashboardUrl),
TelemetryCommandStrings.DashboardConnectionFailedHint),
TokenExchangeFailureKind.ApiNotEnabled => new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, displayDashboardUrl),
TelemetryCommandStrings.DashboardApiNotEnabledHint),
_ => new TelemetryErrorInfo(
TelemetryCommandStrings.DashboardLoginTokenFailed,
TelemetryCommandStrings.DashboardLoginTokenFailedHint,
TelemetryCommandStrings.DashboardLoginTokenFailedAnonymousHint),
};
DisplayTelemetryError(interactionService, errorInfo);
return DashboardApiResult.Failure(CliExitCodes.DashboardFailure);
}
apiKey = exchangeResult.ApiKey;
}
var token = apiKey ?? string.Empty;
return new DashboardApiResult(true, null, dashboardUrl, token, displayDashboardUrl, 0);
}
var result = await connectionResolver.ResolveConnectionAsync(
projectFile,
SharedCommandStrings.ScanningForRunningAppHosts,
string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, TelemetryCommandStrings.SelectAppHostAction),
SharedCommandStrings.AppHostNotRunning,
cancellationToken);
if (!result.Success)
{
var exitCode = AppHostConnectionResultHandler.DisplayFailureAsInformation(result, interactionService);
return DashboardApiResult.Failure(exitCode);
}
var connection = result.Connection!;
var dashboardInfo = await connection.GetDashboardInfoV2Async(cancellationToken);
if (dashboardInfo?.ApiBaseUrl is null || dashboardInfo.ApiToken is null)
{
if (requireDashboard)
{
DisplayTelemetryError(
interactionService,
new TelemetryErrorInfo(
TelemetryCommandStrings.DashboardNotAvailable,
TelemetryCommandStrings.DashboardNotAvailableHint));
return DashboardApiResult.Failure(CliExitCodes.DashboardFailure);
}
// Dashboard is optional — return success with null API info
return new DashboardApiResult(true, connection, null, null, null, 0);
}
var apiBaseUrl = McpToolHelpers.NormalizeDashboardUrl(dashboardInfo.ApiBaseUrl);
// Extract dashboard base URL (without /login path) for hyperlinks.
// Preserve the original hostname (e.g. *.dev.localhost) for display URLs.
var extractedDashboardUrl = ExtractDashboardBaseUrl(dashboardInfo.DashboardUrls?.FirstOrDefault());
return new DashboardApiResult(true, connection, apiBaseUrl, dashboardInfo.ApiToken, extractedDashboardUrl, 0);
}
/// <summary>
/// Strips the /login path segment from a dashboard URL returned by the AppHost.
/// </summary>
internal static string? ExtractDashboardBaseUrl(string? dashboardUrlWithToken)
{
return McpToolHelpers.StripLoginPath(dashboardUrlWithToken);
}
/// <summary>
/// Creates an HTTP client configured for Dashboard API access.
/// </summary>
public static HttpClient CreateApiClient(IHttpClientFactory factory, string apiToken)
{
var client = factory.CreateClient();
if (!string.IsNullOrEmpty(apiToken))
{
client.DefaultRequestHeaders.Add(ApiKeyHeaderName, apiToken);
}
return client;
}
/// <summary>
/// Displays a telemetry error with a structured format: error message and optional hints.
/// The CLI log file path is displayed centrally by BaseCommand on non-zero exit.
/// </summary>
public static void DisplayTelemetryError(
IInteractionService interactionService,
TelemetryErrorInfo errorInfo)
{
interactionService.DisplayError(errorInfo.Error);
foreach (var hint in errorInfo.Hints)
{
interactionService.DisplayMessage(KnownEmojis.Information, hint);
}
}
/// <summary>
/// Formats an error message for a telemetry HTTP failure, using dashboard-specific diagnostics
/// when a direct dashboard URL was provided, or a generic message otherwise.
/// </summary>
public static async Task<TelemetryErrorInfo> FormatTelemetryErrorAsync(
HttpRequestException ex,
string baseUrl,
bool dashboardOnly,
IHttpClientFactory httpClientFactory,
ILogger logger,
CancellationToken cancellationToken)
{
if (dashboardOnly)
{
return await GetDashboardApiErrorAsync(ex, baseUrl, httpClientFactory, logger, cancellationToken);
}
return new TelemetryErrorInfo(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
}
/// <summary>
/// Produces a user-friendly error for dashboard API failures when using --dashboard-url.
/// </summary>
public static async Task<TelemetryErrorInfo> GetDashboardApiErrorAsync(
HttpRequestException ex,
string dashboardBaseUrl,
IHttpClientFactory httpClientFactory,
ILogger logger,
CancellationToken cancellationToken)
{
if (ex.StatusCode == HttpStatusCode.Unauthorized)
{
return new TelemetryErrorInfo(TelemetryCommandStrings.DashboardAuthFailed, TelemetryCommandStrings.DashboardAuthFailedHint, TelemetryCommandStrings.DashboardAuthFailedAnonymousHint);
}
if (ex.StatusCode == HttpStatusCode.NotFound)
{
// Probe the dashboard base URL to distinguish "wrong URL" from "API not enabled"
try
{
using var probeClient = httpClientFactory.CreateClient();
var probeResponse = await probeClient.GetAsync(dashboardBaseUrl, cancellationToken).ConfigureAwait(false);
if (probeResponse.IsSuccessStatusCode)
{
// API is not enabled
return new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardApiNotEnabled, dashboardBaseUrl),
TelemetryCommandStrings.DashboardApiNotEnabledHint);
}
}
catch (Exception probeEx)
{
logger.LogDebug(probeEx, "Dashboard probe failed for {Url}", dashboardBaseUrl);
}
// Dashboard base URL is also not reachable — wrong URL
return new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardUrlNotReachable, dashboardBaseUrl),
TelemetryCommandStrings.DashboardUrlNotReachableHint);
}
if (ex.StatusCode is null)
{
// No HTTP status — connection refused or network error
return new TelemetryErrorInfo(
string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.DashboardConnectionFailed, dashboardBaseUrl),
TelemetryCommandStrings.DashboardConnectionFailedHint);
}
return new TelemetryErrorInfo(string.Format(CultureInfo.CurrentCulture, TelemetryCommandStrings.FailedToFetchTelemetry, ex.Message));
}
/// <summary>
/// Returns a combined error message string for dashboard API failures.
/// Used by MCP tools that return error text rather than using interactive display.
/// </summary>
public static async Task<string> GetDashboardApiErrorMessageAsync(
HttpRequestException ex,
string dashboardBaseUrl,
IHttpClientFactory httpClientFactory,
ILogger logger,
CancellationToken cancellationToken)
{
var errorInfo = await GetDashboardApiErrorAsync(ex, dashboardBaseUrl, httpClientFactory, logger, cancellationToken);
return errorInfo.Hints.Length > 0
? $"{errorInfo.Error} {string.Join(" ", errorInfo.Hints)}"
: errorInfo.Error;
}
/// <summary>
/// Exchanges a frontend login token for an API key by calling the dashboard's
/// <c>POST /api/telemetry/validateToken</c> endpoint.
/// </summary>
/// <returns>A <see cref="TokenExchangeResult"/> indicating success or failure, with the API key when available.</returns>
internal static async Task<TokenExchangeResult> ExchangeLoginTokenForApiKeyAsync(
IHttpClientFactory httpClientFactory,
string dashboardBaseUrl,
string loginToken,
ILogger logger,
CancellationToken cancellationToken)
{
try
{
using var client = httpClientFactory.CreateClient();
var url = DashboardUrls.TelemetryApiKeyUrl(dashboardBaseUrl);
var request = new TelemetryValidateTokenRequest(loginToken);
var response = await client.PostAsJsonAsync(url, request, OtlpJsonSerializerContext.Default.TelemetryValidateTokenRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger.LogDebug("Login token exchange failed with status {StatusCode}", response.StatusCode);
return TokenExchangeResult.FromStatusCode(response.StatusCode);
}
var result = await response.Content.ReadFromJsonAsync(OtlpJsonSerializerContext.Default.TelemetryValidateTokenResponse, cancellationToken).ConfigureAwait(false);
return new TokenExchangeResult(true, result?.ApiKey);
}
catch (HttpRequestException ex)
{
logger.LogDebug(ex, "Failed to exchange login token for API key at {Url}", dashboardBaseUrl);
return TokenExchangeResult.ConnectionError;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to exchange login token for API key at {Url}", dashboardBaseUrl);
return TokenExchangeResult.Failed;
}
}
/// <summary>
/// Resolves an OTLP resource name from the dashboard telemetry resources API into resource filters used by
/// CLI telemetry commands, telemetry export, and telemetry MCP tools. A unique composite name identifies one
/// replica, an ambiguous composite name is rejected, and a base resource name resolves all matching replicas.
/// </summary>
public static bool TryResolveResourceNames(
string? resourceName,
IList<ResourceInfoJson> resources,
out List<string>? resolvedResources)
{
if (string.IsNullOrEmpty(resourceName))
{
// No filter - return true to indicate success
resolvedResources = null;
return true;
}
if (resources is null || resources.Count == 0)
{
resolvedResources = null;
return false;
}
var matches = OtlpHelpers.ResolveResourceNameMatches(resourceName, ToOtlpResources(resources));
if (matches.Count > 0)
{
resolvedResources = matches
.Select(r => r.InstanceId is null ? r.ResourceName : $"{r.ResourceName}-{r.InstanceId}")
.ToList();
return true;
}
// No match found
resolvedResources = null;
return false;
}
public static async Task<ResourceInfoJson[]> GetAllResourcesAsync(HttpClient client, string baseUrl, CancellationToken cancellationToken)
{
var url = DashboardUrls.TelemetryResourcesApiUrl(baseUrl);
var response = await client.GetAsync(url, cancellationToken).ConfigureAwait(false);
EnsureTelemetryApiResponse(response);
var resources = await response.Content.ReadFromJsonAsync(OtlpJsonSerializerContext.Default.ResourceInfoJsonArray, cancellationToken).ConfigureAwait(false) ?? [];
// Sort resources by name for consistent ordering.
Array.Sort(resources, (a, b) =>
{
var cmp = string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
return cmp != 0 ? cmp : string.Compare(a.InstanceId, b.InstanceId, StringComparison.OrdinalIgnoreCase);
});
return resources;
}
/// <summary>
/// Displays a "no data found" message with consistent styling.
/// </summary>
/// <param name="interactionService">The interaction service for output.</param>
/// <param name="dataType">The type of data (e.g., "logs", "spans", "traces").</param>
public static void DisplayNoData(IInteractionService interactionService, string dataType)
{
interactionService.DisplayMarkupLine($"[yellow]No {dataType} found[/]");
}
/// <summary>
/// Creates a Spectre Console hyperlink markup for a trace detail in the Dashboard.
/// </summary>
/// <param name="interactionService">The interaction service to determine link support.</param>
/// <param name="dashboardUrl">The base dashboard URL.</param>
/// <param name="traceId">The trace ID.</param>
/// <param name="displayText">The text to display (defaults to shortened trace ID).</param>
/// <param name="spanId">Optional span ID to highlight in the trace detail view.</param>
/// <returns>
/// A Spectre markup string with hyperlink when the console supports links and dashboardUrl is non-null;
/// or just the display text if links are unsupported, dashboardUrl is null, or traceId is empty.
/// </returns>
public static string FormatTraceLink(IInteractionService interactionService, string? dashboardUrl, string traceId, string? displayText = null, string? spanId = null)
{
var text = displayText ?? OtlpHelpers.ToShortenedId(traceId);
if (string.IsNullOrEmpty(dashboardUrl) || string.IsNullOrEmpty(traceId))
{
return text.EscapeMarkup();
}
// Dashboard trace detail URL: /traces/detail/{traceId}
var url = DashboardUrls.CombineUrl(dashboardUrl, DashboardUrls.TraceDetailUrl(traceId, spanId));
return MarkupHelpers.SafeLink(interactionService, url, text);
}
/// <summary>
/// Formats a duration using the shared DurationFormatter.
/// </summary>
public static string FormatDuration(TimeSpan duration)
{
return DurationFormatter.FormatDuration(duration, CultureInfo.InvariantCulture);
}
/// <summary>
/// Gets abbreviated severity text for an OTLP severity number.
/// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL
/// </summary>
public static string GetSeverityText(int? severityNumber)
{
return severityNumber switch
{
>= 21 => CliLogFormat.FileLevelTokens.Critical,
>= 17 => CliLogFormat.FileLevelTokens.Error,
>= 13 => CliLogFormat.FileLevelTokens.Warning,
>= 9 => CliLogFormat.FileLevelTokens.Information,
>= 5 => CliLogFormat.FileLevelTokens.Debug,
>= 1 => CliLogFormat.FileLevelTokens.Trace,
_ => "-"
};
}
/// <summary>
/// Gets Spectre Console color for a log severity number.
/// OTLP severity numbers: 1-4=TRACE, 5-8=DEBUG, 9-12=INFO, 13-16=WARN, 17-20=ERROR, 21-24=FATAL
/// </summary>
public static Color GetSeverityColor(int? severityNumber)
{
return severityNumber switch
{
>= 17 => Color.Red, // ERROR/FATAL
>= 13 => Color.Yellow, // WARN
>= 9 => Color.Blue, // INFO
>= 5 => Color.Grey, // DEBUG
>= 1 => Color.Grey, // TRACE
_ => Color.White
};
}
/// <summary>
/// Reads lines from an HTTP streaming response, yielding each complete line as it arrives.
/// </summary>
public static async IAsyncEnumerable<string> ReadLinesAsync(
this StreamReader reader,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
yield break;
}
if (!string.IsNullOrEmpty(line))
{
yield return line;
}
}
}
/// <summary>
/// Converts resource information to a list of <see cref="IOtlpResource"/> values.
/// </summary>
public static IReadOnlyList<IOtlpResource> ToOtlpResources(IList<ResourceInfoJson> resources)
{
var result = new IOtlpResource[resources.Count];
for (var i = 0; i < resources.Count; i++)
{
result[i] = new SimpleOtlpResource(resources[i].Name, resources[i].InstanceId);
}
return result;
}
/// <summary>
/// Pre-resolves resource colors for all resources in sorted order so that
/// color assignment is deterministic regardless of encounter order in telemetry data.
/// </summary>
public static void ResolveResourceColors(ResourceColorMap colorMap, IReadOnlyList<IOtlpResource> allResources)
{
colorMap.ResolveAll(allResources.Select(r => OtlpHelpers.GetResourceName(r, allResources)));
}
/// <summary>
/// Resolves the display name for an OTLP resource using <see cref="OtlpHelpers.GetResourceName"/>,
/// appending a shortened instance ID when there are replicas with the same base name.
/// </summary>
public static string ResolveResourceName(OtlpResourceJson? resource, IReadOnlyList<IOtlpResource> allResources)
{
if (resource is null)
{
return "unknown";
}
var otlpResource = new SimpleOtlpResource(resource.GetServiceName(), resource.GetServiceInstanceId());
return OtlpHelpers.GetResourceName(otlpResource, allResources);
}
}
/// <summary>
/// Result of resolving the Dashboard API connection via <see cref="TelemetryCommandHelpers.GetDashboardApiAsync"/>.
/// </summary>
/// <param name="Success">Whether the resolution succeeded.</param>
/// <param name="Connection">The AppHost backchannel connection, if resolved via an AppHost.</param>
/// <param name="BaseUrl">The Dashboard API base URL, or <c>null</c> if the dashboard is unavailable.</param>
/// <param name="ApiToken">The Dashboard API authentication token, or <c>null</c> if the dashboard is unavailable.</param>
/// <param name="DashboardUrl">The Dashboard UI base URL for hyperlinks, or <c>null</c> if unavailable.</param>
/// <param name="ExitCode">The exit code to return when <paramref name="Success"/> is <c>false</c>.</param>
internal sealed record DashboardApiResult(
bool Success,
IAppHostAuxiliaryBackchannel? Connection,
string? BaseUrl,
string? ApiToken,
string? DashboardUrl,
int ExitCode)
{
/// <summary>
/// Creates a failed result with the specified exit code.
/// </summary>
public static DashboardApiResult Failure(int exitCode)
=> new(false, null, null, null, null, exitCode);
}
/// <summary>
/// Describes the kind of failure that occurred during a login token exchange.
/// </summary>
internal enum TokenExchangeFailureKind
{
/// <summary>No failure (exchange succeeded).</summary>
None,
/// <summary>The token was invalid or rejected by the dashboard (401).</summary>
TokenRejected,
/// <summary>The telemetry API is not enabled on the dashboard (404).</summary>
ApiNotEnabled,
/// <summary>The dashboard was not reachable (connection error).</summary>
ConnectionError,
/// <summary>An unexpected HTTP status code was returned.</summary>
Other,
}
/// <summary>
/// Result of exchanging a frontend login token for an API key via the dashboard.
/// </summary>
/// <param name="Success">Whether the exchange succeeded. When <c>false</c>, the token was invalid or the endpoint was unreachable.</param>
/// <param name="ApiKey">The API key returned by the dashboard, or <c>null</c> if the dashboard API is unsecured.</param>
/// <param name="FailureKind">The kind of failure when <paramref name="Success"/> is <c>false</c>.</param>
internal sealed record TokenExchangeResult(bool Success, string? ApiKey, TokenExchangeFailureKind FailureKind = TokenExchangeFailureKind.None)
{
/// <summary>
/// A failed token exchange result due to an invalid or rejected token.
/// </summary>
public static readonly TokenExchangeResult Failed = new(false, null, TokenExchangeFailureKind.TokenRejected);
/// <summary>
/// A failed token exchange result due to a connection error.
/// </summary>
public static readonly TokenExchangeResult ConnectionError = new(false, null, TokenExchangeFailureKind.ConnectionError);
/// <summary>
/// Creates a failed result from an HTTP status code.
/// </summary>
public static TokenExchangeResult FromStatusCode(HttpStatusCode statusCode) => statusCode switch
{
HttpStatusCode.NotFound => new(false, null, TokenExchangeFailureKind.ApiNotEnabled),
HttpStatusCode.Unauthorized => new(false, null, TokenExchangeFailureKind.TokenRejected),
_ => new(false, null, TokenExchangeFailureKind.Other),
};
}
/// <summary>
/// Structured error information for telemetry commands, containing the error message and optional remediation hints.
/// </summary>
/// <param name="Error">The error message describing what went wrong.</param>
/// <param name="Hints">Optional hints describing how to fix the problem. Each hint is displayed on a separate line.</param>
internal sealed record TelemetryErrorInfo(string Error, params string[] Hints);