using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Extensions; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; /// /// Client for observability API operations. /// Per CLI-OBS-51-001. /// internal sealed class ObservabilityClient : IObservabilityClient { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); private readonly HttpClient httpClient; private readonly StellaOpsCliOptions options; private readonly ILogger logger; private readonly IStellaOpsTokenClient? tokenClient; private readonly object tokenSync = new(); private string? cachedAccessToken; private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public ObservabilityClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.options = options ?? throw new ArgumentNullException(nameof(options)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.tokenClient = tokenClient; if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task GetHealthSummaryAsync( ObsTopRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var requestUri = BuildHealthSummaryUri(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get health summary (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ObsTopResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var summary = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return new ObsTopResult { Success = true, Summary = summary ?? new PlatformHealthSummary() }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while fetching health summary"); return new ObsTopResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while fetching health summary"); return new ObsTopResult { Success = false, Errors = ["Request timed out"] }; } } private static string BuildHealthSummaryUri(ObsTopRequest request) { var queryParams = new List(); if (request.Services.Count > 0) { foreach (var service in request.Services) { queryParams.Add($"service={Uri.EscapeDataString(service)}"); } } if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } queryParams.Add($"includeQueues={request.IncludeQueues.ToString().ToLowerInvariant()}"); queryParams.Add($"maxAlerts={request.MaxAlerts}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; return $"/api/v1/observability/health{query}"; } private void EnsureConfigured() { if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null) { throw new InvalidOperationException( "Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url."); } } private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private async Task GetAccessTokenAsync(CancellationToken cancellationToken) { if (tokenClient is null) { return null; } lock (tokenSync) { if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew) { return cachedAccessToken; } } try { var result = await tokenClient.GetAccessTokenAsync(StellaOpsScopes.ObservabilityRead, cancellationToken).ConfigureAwait(false); lock (tokenSync) { cachedAccessToken = result.AccessToken; cachedAccessTokenExpiresAt = result.ExpiresAt; } return result.AccessToken; } catch (Exception ex) { logger.LogWarning(ex, "Token acquisition failed"); return null; } } // CLI-OBS-52-001: Trace retrieval public async Task GetTraceAsync( ObsTraceRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var requestUri = BuildTraceUri(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return new ObsTraceResult { Success = false, Errors = [$"Trace not found: {request.TraceId}"] }; } if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get trace (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ObsTraceResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var trace = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return new ObsTraceResult { Success = true, Trace = trace }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while fetching trace"); return new ObsTraceResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while fetching trace"); return new ObsTraceResult { Success = false, Errors = ["Request timed out"] }; } } private static string BuildTraceUri(ObsTraceRequest request) { var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } queryParams.Add($"includeEvidence={request.IncludeEvidence.ToString().ToLowerInvariant()}"); var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; return $"/api/v1/observability/traces/{Uri.EscapeDataString(request.TraceId)}{query}"; } // CLI-OBS-52-001: Logs retrieval public async Task GetLogsAsync( ObsLogsRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var requestUri = BuildLogsUri(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, requestUri); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get logs (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new ObsLogsResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new ObsLogsResult { Success = true }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while fetching logs"); return new ObsLogsResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while fetching logs"); return new ObsLogsResult { Success = false, Errors = ["Request timed out"] }; } } private static string BuildLogsUri(ObsLogsRequest request) { var queryParams = new List { $"from={Uri.EscapeDataString(request.From.ToString("o"))}", $"to={Uri.EscapeDataString(request.To.ToString("o"))}" }; if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } foreach (var service in request.Services) { queryParams.Add($"service={Uri.EscapeDataString(service)}"); } foreach (var level in request.Levels) { queryParams.Add($"level={Uri.EscapeDataString(level)}"); } if (!string.IsNullOrWhiteSpace(request.Query)) { queryParams.Add($"q={Uri.EscapeDataString(request.Query)}"); } queryParams.Add($"pageSize={request.PageSize}"); if (!string.IsNullOrWhiteSpace(request.PageToken)) { queryParams.Add($"pageToken={Uri.EscapeDataString(request.PageToken)}"); } var query = "?" + string.Join("&", queryParams); return $"/api/v1/observability/logs{query}"; } // CLI-OBS-55-001: Incident mode operations public async Task GetIncidentModeStatusAsync( string? tenant, CancellationToken cancellationToken) { try { EnsureConfigured(); var query = !string.IsNullOrWhiteSpace(tenant) ? $"?tenant={Uri.EscapeDataString(tenant)}" : string.Empty; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/observability/incident-mode{query}"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get incident mode status (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new IncidentModeResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var state = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return new IncidentModeResult { Success = true, State = state }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while fetching incident mode status"); return new IncidentModeResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while fetching incident mode status"); return new IncidentModeResult { Success = false, Errors = ["Request timed out"] }; } } public async Task EnableIncidentModeAsync( IncidentModeEnableRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/enable"); var json = JsonSerializer.Serialize(request, SerializerOptions); httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to enable incident mode (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new IncidentModeResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new IncidentModeResult { Success = true }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while enabling incident mode"); return new IncidentModeResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while enabling incident mode"); return new IncidentModeResult { Success = false, Errors = ["Request timed out"] }; } } public async Task DisableIncidentModeAsync( IncidentModeDisableRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/observability/incident-mode/disable"); var json = JsonSerializer.Serialize(request, SerializerOptions); httpRequest.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to disable incident mode (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new IncidentModeResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new IncidentModeResult { Success = true }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while disabling incident mode"); return new IncidentModeResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while disabling incident mode"); return new IncidentModeResult { Success = false, Errors = ["Request timed out"] }; } } }