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"]
};
}
}
}