diff --git a/README.md b/README.md index b5e39bc..be4feae 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,13 @@ avisa quando há versão nova publicada aqui, com um link para o release. - **Stack Trace Highlighter:** Exceções são formatadas e coloridas para facilitar a leitura. - **Propriedades Estruturadas:** Visualize todas as propriedades do evento de log em uma tabela organizada. - **Correlation ID:** Clique em IDs de correlação para filtrar todos os logs relacionados a uma mesma requisição. +- **Navegação por Correlação:** A partir de um evento, monte a sequência dos eventos que compartilham TraceId, SpanId, RequestId ou CorrelationId — sem mexer nos filtros da consulta atual. +- **Linha do Tempo de Spans:** Durações, intervalos entre eventos e hierarquia pai/filho a partir dos campos de tracing do CLEF (`@tr`, `@sp`, `@ps`, `@st`) e de atributos OpenTelemetry/Seq. ### ⚙️ Configurações - **Ignorar Arquivos:** Defina padrões (wildcards) para ignorar arquivos indesejados (ex: *backup*). - **Ignorar Linhas:** Configure textos para ocultar linhas de log que são ruído (ex: health checks). +- **Aliases de Correlação e Observabilidade:** Informe os nomes que sua aplicação usa para correlação, nome da operação, serviço, tipo do span e duração — os campos padrão continuam reconhecidos sem configuração. ## 📋 Pré-requisitos diff --git a/src/Components/LogCorrelationNavigator.razor b/src/Components/LogCorrelationNavigator.razor new file mode 100644 index 0000000..00ab468 --- /dev/null +++ b/src/Components/LogCorrelationNavigator.razor @@ -0,0 +1,202 @@ +@using ClefExplorer.Models +@using ClefExplorer.Helpers + +
+
+
+

Eventos correlacionados

+
@ResumoSequencia()
+
+
+ + + + + +
+
+ +

Como os eventos foram relacionados

+
+

A sequência usa correspondência direta com o evento de origem: mesmo campo e valor.

+
+ @foreach (var identificador in Resultado.Identificadores) + { + + @identificador.Campo + @identificador.Valor + + } +
+

+ As linhas azuis na lista/tabela fazem parte desta sequência. Uma linha sem destaque entre elas pode indicar trabalho concorrente. +

+
+
+
+ +
+
+ + @if (Resultado.QuantidadeRelacionada == 0) + { +
+ +
+ } + else + { + + +
+ + + @{ + var atual = ReferenceEquals(SelectedEvent, item.Evento); + var origem = ReferenceEquals(Resultado.Origem, item.Evento); + } + + + +
+
+ + + +
+ } +
+ +@code { + [Parameter, EditorRequired] + public ResultadoNavegacaoCorrelacao Resultado { get; set; } = null!; + + [Parameter, EditorRequired] + public ResultadoAnaliseTemporalCorrelacao AnaliseTemporal { get; set; } = null!; + + [Parameter] + public ClefEvent? SelectedEvent { get; set; } + + [Parameter] + public EventCallback OnSelect { get; set; } + + [Parameter] + public EventCallback OnClose { get; set; } + + private ICollection _eventos = Array.Empty(); + private bool _criteriaOpen; + private bool _mostrarData; + private bool _mostrarFonte; + private int _quantidadeFontes; + + protected override void OnParametersSet() + { + _eventos = Resultado.Eventos as ICollection + ?? Resultado.Eventos.ToArray(); + + _mostrarData = Resultado.Eventos + .Select(item => item.Evento.Timestamp?.Date) + .Where(data => data is not null) + .Distinct() + .Take(2) + .Count() > 1; + + _quantidadeFontes = Resultado.Eventos + .Select(item => item.Evento.SourceFile) + .Where(caminho => !string.IsNullOrWhiteSpace(caminho)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + _mostrarFonte = _quantidadeFontes > 1; + } + + private static string TextoQuantidade(int quantidade) => quantidade == 1 + ? "1 evento na sequência" + : $"{quantidade:N0} eventos na sequência"; + + private string ResumoSequencia() + { + var partes = new List(3) { TextoQuantidade(Resultado.Eventos.Count) }; + if (AnaliseTemporal.IntervaloTotal > TimeSpan.Zero) + { + partes.Add($"{FormatacaoTempo.Intervalo(AnaliseTemporal.IntervaloTotal)} de intervalo"); + } + + if (_mostrarFonte) + { + partes.Add($"{_quantidadeFontes:N0} fontes"); + } + + return string.Join(" · ", partes); + } + + private string FormatarInstante(DateTimeOffset? instante) => instante is null + ? "—" + : instante.Value.ToString(_mostrarData ? "dd/MM HH:mm:ss.fff" : "HH:mm:ss.fff"); + + private static string Mensagem(ClefEvent evento) => + evento.Message ?? evento.MessageTemplate ?? "(evento sem mensagem)"; + + private static string CamposCorrespondentes(EventoCorrelacionado item) => + string.Join(", ", item.Correspondencias.Select(c => c.Campo).Distinct(StringComparer.OrdinalIgnoreCase)); + + private static string RotuloAcessivel(EventoCorrelacionado item) + { + var instante = item.Evento.Timestamp?.ToString("dd/MM/yyyy HH:mm:ss.fff") ?? "sem data"; + return $"{item.Evento.Level}, {instante}: {Mensagem(item.Evento)}. Relacionado por {CamposCorrespondentes(item)}"; + } +} diff --git a/src/Components/LogCorrelationTiming.razor b/src/Components/LogCorrelationTiming.razor new file mode 100644 index 0000000..fa373bd --- /dev/null +++ b/src/Components/LogCorrelationTiming.razor @@ -0,0 +1,282 @@ +@using System.Globalization +@using ClefExplorer.Helpers +@using ClefExplorer.Models + +
+ @if (Analise.Itens.Count == 0 || Analise.Inicio is null || Analise.Fim is null) + { +
+ +
+ } + else + { +
+
+ @FormatacaoTempo.Intervalo(Analise.IntervaloTotal) + na janela observada +
+
+ @if (Analise.TemDuracoesReais) + { + Duração real do span + } + @if (Analise.TemDuracoesInformadas) + { + Duração informada + } + @if (Analise.TemIntervalosEstimados) + { + Intervalo até o próximo log (≈) + } + Evento pontual +
+ @if (Analise.TemIntervalosEstimados) + { + + Intervalos entre logs são indicativos; não representam tempo de execução. + + } +
+ +
+
+ + + @foreach (var noVisivel in _nosVisiveis) + { + var no = noVisivel.No; + var item = no.Item; + var selecionado = ReferenceEquals(SelectedEvent, item.Evento); + var temFilhos = no.Filhos.Count > 0; + var recolhido = EstaRecolhido(no); +
+ + @if (temFilhos && no.EhSpan && !string.IsNullOrWhiteSpace(no.SpanId)) + { + + } + else + { + + } + + + + @FormatarInstante(item.Evento.Timestamp) + + @if (no.EhSpan && !string.IsNullOrWhiteSpace(item.Metadados.TipoSpan)) + { + @item.Metadados.TipoSpan + } + @if (no.EhSpan && !string.IsNullOrWhiteSpace(item.Metadados.NomeServico)) + { + + @item.Metadados.NomeServico + + } + + @Prefixo(item.Tipo)@FormatacaoTempo.Intervalo(item.Intervalo) + + + @NomeExibido(item) + + + +
+ } +
+
+ } +
+ +@code { + [Parameter, EditorRequired] + public ResultadoAnaliseTemporalCorrelacao Analise { get; set; } = null!; + + [Parameter] + public ClefEvent? SelectedEvent { get; set; } + + [Parameter] + public EventCallback OnSelect { get; set; } + + private readonly HashSet _spansRecolhidos = new(StringComparer.OrdinalIgnoreCase); + private readonly List _nosVisiveis = new(); + private ResultadoAnaliseTemporalCorrelacao? _analiseAnterior; + private bool _mostrarData; + + protected override void OnParametersSet() + { + if (!ReferenceEquals(_analiseAnterior, Analise)) + { + _analiseAnterior = Analise; + _spansRecolhidos.Clear(); + } + + _mostrarData = Analise.Itens + .Select(item => item.Evento.Timestamp?.Date) + .Where(data => data is not null) + .Distinct() + .Take(2) + .Count() > 1; + + AtualizarNosVisiveis(); + } + + private void AtualizarNosVisiveis() + { + _nosVisiveis.Clear(); + foreach (var raiz in Analise.Hierarquia) + { + AdicionarVisiveis(raiz, 0); + } + } + + private void AdicionarVisiveis(NoHierarquiaSpan no, int nivel) + { + _nosVisiveis.Add(new NoVisivel(no, nivel)); + if (EstaRecolhido(no)) return; + + foreach (var filho in no.Filhos) + { + AdicionarVisiveis(filho, nivel + 1); + } + } + + private bool EstaRecolhido(NoHierarquiaSpan no) => + no.SpanId is { } spanId && _spansRecolhidos.Contains(spanId); + + private void Alternar(NoHierarquiaSpan no) + { + if (no.SpanId is not { } spanId) return; + if (!_spansRecolhidos.Add(spanId)) _spansRecolhidos.Remove(spanId); + AtualizarNosVisiveis(); + } + + private Task Selecionar(ClefEvent evento) => ReferenceEquals(SelectedEvent, evento) + ? Task.CompletedTask + : OnSelect.InvokeAsync(evento); + + private async Task AoPressionar(KeyboardEventArgs args, NoHierarquiaSpan no) + { + if (args.Key is "Enter" or " ") + { + await Selecionar(no.Item.Evento); + } + else if (args.Key == "ArrowLeft" && !EstaRecolhido(no) && no.Filhos.Count > 0) + { + Alternar(no); + } + else if (args.Key == "ArrowRight" && EstaRecolhido(no)) + { + Alternar(no); + } + } + + private string Estilo(ItemAnaliseTemporalCorrelacao item) + { + var totalTicks = Math.Max(1, Analise.IntervaloTotal.Ticks); + var inicioTicks = Math.Max(0, (item.Inicio - Analise.Inicio!.Value).Ticks); + var inicio = Math.Clamp(inicioTicks * 100d / totalTicks, 0, 100); + var largura = Math.Clamp(item.Intervalo.Ticks * 100d / totalTicks, 0, 100 - inicio); + + return $"--clef-time-start:{inicio.ToString("0.####", CultureInfo.InvariantCulture)}%;" + + $"--clef-time-width:{largura.ToString("0.####", CultureInfo.InvariantCulture)}%"; + } + + private static string EstiloProfundidade(int nivel) => + $"--clef-tree-depth:{Math.Min(nivel, 12)}"; + + private string FormatarInstante(DateTimeOffset? instante) => instante is null + ? "—" + : instante.Value.ToString(_mostrarData ? "dd/MM HH:mm:ss.fff" : "HH:mm:ss.fff"); + + private static string ClasseTipo(TipoMedicaoTemporalCorrelacao tipo) => tipo switch + { + TipoMedicaoTemporalCorrelacao.DuracaoRealDoSpan => "is-real", + TipoMedicaoTemporalCorrelacao.DuracaoInformadaPeloProdutor => "is-reported", + TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento => "is-estimated", + _ => "is-point", + }; + + private static string Prefixo(TipoMedicaoTemporalCorrelacao tipo) => + tipo == TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento ? "≈ " : string.Empty; + + private static string Mensagem(ClefEvent evento) => + evento.Message ?? evento.MessageTemplate ?? "(evento sem mensagem)"; + + private static string NomeExibido(ItemAnaliseTemporalCorrelacao item) => + item.Metadados.EhSpan ? item.Metadados.NomeOperacao : Mensagem(item.Evento); + + private static string DescricaoDuracao(ItemAnaliseTemporalCorrelacao item) => + item.Metadados.OrigemDuracao switch + { + OrigemDuracaoObservabilidade.SeqClef => "Duração real calculada por @st → @t (Seq/CLEF)", + OrigemDuracaoObservabilidade.OpenTelemetryOtlp => + "Duração real calculada por startTimeUnixNano → endTimeUnixNano (OpenTelemetry)", + OrigemDuracaoObservabilidade.CampoConfigurado => + $"Duração informada por {item.Metadados.CampoDuracao}", + _ when item.Tipo == TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento => + "Intervalo indicativo até o próximo log; não representa tempo de execução", + _ => "Evento pontual", + }; + + private static string RotuloAcessivel(ItemAnaliseTemporalCorrelacao item) + { + var instante = item.Evento.Timestamp?.ToString("dd/MM/yyyy HH:mm:ss.fff") ?? "sem data"; + var medida = item.Tipo switch + { + TipoMedicaoTemporalCorrelacao.DuracaoRealDoSpan => + $"duração real do span de {FormatacaoTempo.Intervalo(item.Intervalo)}", + TipoMedicaoTemporalCorrelacao.DuracaoInformadaPeloProdutor => + $"duração informada de {FormatacaoTempo.Intervalo(item.Intervalo)}", + TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento => + $"intervalo indicativo de {FormatacaoTempo.Intervalo(item.Intervalo)} até o próximo log", + _ => "evento pontual", + }; + + var servico = string.IsNullOrWhiteSpace(item.Metadados.NomeServico) + ? string.Empty + : $", serviço {item.Metadados.NomeServico}"; + return $"{item.Evento.Level}, {instante}: {NomeExibido(item)}{servico}; {medida}"; + } + + private sealed record NoVisivel(NoHierarquiaSpan No, int Nivel); +} diff --git a/src/Components/LogDetails.razor b/src/Components/LogDetails.razor index 3efd017..7f63a38 100644 --- a/src/Components/LogDetails.razor +++ b/src/Components/LogDetails.razor @@ -2,6 +2,9 @@ @using ClefExplorer.Helpers @using Serilog.Events @inject IJSRuntime JSRuntime +@inject LeituraMetadadosObservabilidade LeituraObservabilidade +@inject SettingsService SettingsService +@inject NavegacaoCorrelacao Navegacao
@if (SelectedEvent != null) @@ -12,7 +15,18 @@ @SelectedEvent.Timestamp?.ToString("dd/MM/yyyy") @SelectedEvent.Timestamp?.ToString("HH:mm:ss.fff zzz")
- +
+ @if (CanNavigateCorrelation) + { + + } + +
@@ -45,6 +59,73 @@ + @if (!string.IsNullOrWhiteSpace(_observability?.TraceId)) + { + + TraceId + @_observability.TraceId + + } + @if (!string.IsNullOrWhiteSpace(_observability?.SpanId)) + { + + SpanId + @_observability.SpanId + + } + @if (!string.IsNullOrWhiteSpace(_observability?.ParentSpanId)) + { + + ParentSpanId + @_observability.ParentSpanId + + } + @if (_observability?.EhSpan == true) + { + + Operação + @_observability.NomeOperacao + + @if (!string.IsNullOrWhiteSpace(_observability.NomeServico)) + { + + Serviço + @_observability.NomeServico + + } + @if (!string.IsNullOrWhiteSpace(_observability.TipoSpan)) + { + + Tipo do span + @_observability.TipoSpan + + } + + Início do span + @_observability.Inicio!.Value.ToString("dd/MM/yyyy HH:mm:ss.fffffff zzz") + + + Duração do span + + @FormatacaoTempo.Intervalo(_observability.Duracao!.Value) + @DurationOrigin(_observability) + + + } + @if (SelectedEvent.ObservabilidadeClef?.EscopoInstrumentacao is { } scope) + { + + Escopo de instrumentação (@@sc) + + + } + @if (SelectedEvent.ObservabilidadeClef?.AtributosRecurso is { } resourceAttributes) + { + + Atributos do recurso (@@ra) + + + } @if (SelectedEvent.Properties != null) { @foreach (var p in SelectedEvent.Properties.OrderBy(x => x.Key)) @@ -58,7 +139,7 @@
@StackTraceHighlighter.Highlight(s)
} - else if (p.Key.Equals("X-Correlation-Id", StringComparison.OrdinalIgnoreCase) && p.Value is ScalarValue svCorr && svCorr.Value is string sCorr) + else if (Navegacao.EhCampoCorrelacao(p.Key) && p.Value is ScalarValue svCorr && svCorr.Value is string sCorr) { var ids = sCorr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@@ -98,6 +179,30 @@ [Parameter] public ClefEvent? SelectedEvent { get; set; } [Parameter] public EventCallback OnClose { get; set; } [Parameter] public EventCallback OnFilter { get; set; } + [Parameter] public bool CanNavigateCorrelation { get; set; } + [Parameter] public bool IsLocatingCorrelation { get; set; } + [Parameter] public EventCallback OnNavigateCorrelation { get; set; } + + private MetadadosObservabilidadeEvento? _observability; + + protected override void OnParametersSet() + { + _observability = SelectedEvent is null + ? null + : LeituraObservabilidade.Extrair( + SelectedEvent, + SettingsService.Settings.Observabilidade); + } + + private static string DurationOrigin(MetadadosObservabilidadeEvento observability) => + observability.OrigemDuracao switch + { + OrigemDuracaoObservabilidade.SeqClef => "Seq/CLEF", + OrigemDuracaoObservabilidade.OpenTelemetryOtlp => "OpenTelemetry", + OrigemDuracaoObservabilidade.CampoConfigurado => + $"campo {observability.CampoDuracao}", + _ => string.Empty, + }; private async Task CopyException() { diff --git a/src/Components/LogGrid.razor b/src/Components/LogGrid.razor index 01b33c9..43accd4 100644 --- a/src/Components/LogGrid.razor +++ b/src/Components/LogGrid.razor @@ -225,6 +225,7 @@ [Parameter] public IReadOnlyList Eventos { get; set; } = Array.Empty(); [Parameter] public ClefEvent? SelectedEvent { get; set; } + [Parameter] public IReadOnlySet CorrelatedEvents { get; set; } = new HashSet(); [Parameter] public EventCallback OnSelect { get; set; } /// Colunas descobertas no conteúdo dos logs carregados. @@ -275,11 +276,12 @@ private static string TemplateOuMensagem(ClefEvent e) => !string.IsNullOrEmpty(e.MessageTemplate) ? e.MessageTemplate : e.Message ?? string.Empty; - /// Destaca a linha selecionada e as de erro, como na lista. + /// Destaca linhas correlacionadas, a selecionada e as de erro, como na lista. private string LinhaCss(ClefEvent e) { - var classes = new List(2); + var classes = new List(3); + if (CorrelatedEvents.Contains(e)) classes.Add("is-correlated"); if (ReferenceEquals(e, SelectedEvent)) classes.Add("is-selected"); if (string.Equals(e.Level, "Error", StringComparison.OrdinalIgnoreCase) diff --git a/src/Components/LogList.razor b/src/Components/LogList.razor index 389b02a..e735015 100644 --- a/src/Components/LogList.razor +++ b/src/Components/LogList.razor @@ -20,7 +20,7 @@ @* Height="@null" e não Height="null": o segundo passaria a STRING "null". *@ -
Eventos { get; set; } = Enumerable.Empty(); [Parameter] public ClefEvent? SelectedEvent { get; set; } + [Parameter] public IReadOnlySet CorrelatedEvents { get; set; } = new HashSet(); [Parameter] public EventCallback OnSelect { get; set; } /// @@ -81,11 +82,20 @@ /// Nome anunciado pelo leitor de tela. Sem ele, o item seria lido como um amontoado de /// textos soltos — e o nível, que visualmente é um badge colorido, se perderia. /// - private static string RotuloAcessivel(ClefEvent e) + private string RotuloAcessivel(ClefEvent e) { var hora = e.Timestamp?.ToString("HH:mm:ss") ?? ""; var excecao = string.IsNullOrEmpty(e.Exception) ? "" : ", com exceção"; - return $"{e.Level} às {hora}: {e.Message}{excecao}"; + var correlacionado = CorrelatedEvents.Contains(e) ? ", evento correlacionado" : ""; + return $"{e.Level} às {hora}: {e.Message}{excecao}{correlacionado}"; + } + + private string ItemClass(ClefEvent evento) + { + var classes = new List(2); + if (CorrelatedEvents.Contains(evento)) classes.Add("is-correlated"); + if (ReferenceEquals(SelectedEvent, evento)) classes.Add("is-selected"); + return string.Join(' ', classes); } private string ObterResumoProps(ClefEvent e) diff --git a/src/Components/LogViewer.razor b/src/Components/LogViewer.razor index 5ae1881..274ff01 100644 --- a/src/Components/LogViewer.razor +++ b/src/Components/LogViewer.razor @@ -14,6 +14,8 @@ @inject NotificationService Notifications @inject UiPreferencesService UiPreferences @inject ConsultaLogs Consulta +@inject NavegacaoCorrelacao Navegacao +@inject AnaliseTemporalCorrelacao AnaliseTemporal @inject ServicoAtualizacao Atualizacao @inject ExploradorArquivos Explorador @inject IJSRuntime JS @@ -123,7 +125,35 @@
- + @if (_resultadoCorrelacao is not null) + { + @* À direita, empilhamos correlação sobre detalhes para não + espremer dois painéis estreitos. Com o detalhe abaixo da + lista há largura disponível, então eles ficam lado a lado. *@ + + +
+ +
+
+ +
+ @RenderDetalhes() +
+
+
+ } + else + { + @RenderDetalhes() + }
@@ -144,6 +174,16 @@ @* Lista ou tabela conforme a preferência — o resto do layout (splitter, detalhe) é o mesmo nos dois modos, então só o miolo troca. *@ @code { + private RenderFragment RenderDetalhes() => __builder => + { + + }; + private RenderFragment RenderEventos() => __builder => { @* Carregando e ainda sem nada para mostrar: o miolo inteiro vira o indicador de @@ -202,6 +242,7 @@ } }; @@ -224,6 +266,11 @@ private bool EstaCarregandoVazio => Store.IsLoading && _todosEventos.Count == 0; private ClefEvent? _selected; + private ResultadoNavegacaoCorrelacao? _resultadoCorrelacao; + private ResultadoAnaliseTemporalCorrelacao? _analiseTemporalCorrelacao; + private HashSet _eventosCorrelacionados = new(); + private bool _buscandoCorrelacao; + private CancellationTokenSource? _correlationCts; private bool _isBusy; private bool _descartado; @@ -649,6 +696,7 @@ { Interlocked.Exchange(ref _debounceCts, null), Interlocked.Exchange(ref _exportCts, null), + Interlocked.Exchange(ref _correlationCts, null), }) { if (campo is null) continue; @@ -1122,6 +1170,7 @@ private void Select(ClefEvent e) { + CancelarBuscaCorrelacao(); _selected = e; // A lista/grade entrega o evento, não a posição: invalidar aqui faz a próxima // seta pagar uma varredura e as seguintes andarem pelo índice guardado. @@ -1130,9 +1179,90 @@ private void CloseDetail() { + LimparNavegacaoCorrelacao(); _selected = null; } + private async Task AbrirNavegacaoCorrelacao() + { + var origem = _selected; + if (origem is null || !Navegacao.PodeNavegar(origem)) return; + + var cts = new CancellationTokenSource(); + var anterior = Interlocked.Exchange(ref _correlationCts, cts); + if (anterior is not null) + { + try { anterior.Cancel(); } catch (ObjectDisposedException) { } + anterior.Dispose(); + } + + _buscandoCorrelacao = true; + StateHasChanged(); + + try + { + // O snapshot é imutável por contrato. A varredura pode sair do dispatcher sem + // bloquear a janela, mesmo quando há centenas de milhares de eventos. + var snapshot = Store.Snapshot(); + var resultado = await Task.Run( + () => Navegacao.Localizar(origem, snapshot, cts.Token), + cts.Token); + + if (!_descartado && ReferenceEquals(_selected, origem) && !cts.IsCancellationRequested) + { + _resultadoCorrelacao = resultado; + _analiseTemporalCorrelacao = AnaliseTemporal.Analisar( + resultado, + SettingsService.Settings.Observabilidade); + _eventosCorrelacionados = resultado.Eventos + .Select(item => item.Evento) + .ToHashSet(); + } + } + catch (OperationCanceledException) + { + // O usuário selecionou outro evento ou fechou o detalhe durante a busca. + } + catch (Exception ex) + { + AppLog.Error("Falha ao localizar eventos correlacionados", ex); + Notifications.Error("Não foi possível navegar pela correlação", ex.Message); + } + finally + { + var aindaAtual = ReferenceEquals( + Interlocked.CompareExchange(ref _correlationCts, null, cts), + cts); + cts.Dispose(); + if (aindaAtual) _buscandoCorrelacao = false; + } + } + + private void SelecionarDaCorrelacao(ClefEvent evento) => Select(evento); + + private void FecharNavegacaoCorrelacao() + => LimparNavegacaoCorrelacao(); + + private void LimparNavegacaoCorrelacao() + { + CancelarBuscaCorrelacao(); + _resultadoCorrelacao = null; + _analiseTemporalCorrelacao = null; + _eventosCorrelacionados.Clear(); + } + + private void CancelarBuscaCorrelacao() + { + var cts = Interlocked.Exchange(ref _correlationCts, null); + if (cts is not null) + { + try { cts.Cancel(); } catch (ObjectDisposedException) { } + cts.Dispose(); + } + + _buscandoCorrelacao = false; + } + private void FiltrarPorTexto(string texto) { TextoPesquisa = texto; diff --git a/src/Components/ObservabilityAliasEditor.razor b/src/Components/ObservabilityAliasEditor.razor new file mode 100644 index 0000000..3214b6d --- /dev/null +++ b/src/Components/ObservabilityAliasEditor.razor @@ -0,0 +1,77 @@ +
+
+ + @Hint +
+
+ @foreach (var alias in Values) + { + var currentAlias = alias; + + @currentAlias + + + } +
+
+ + +
+
+ +@code { + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + [Parameter, EditorRequired] + public string Hint { get; set; } = string.Empty; + + [Parameter, EditorRequired] + public List Values { get; set; } = null!; + + [Parameter] + public EventCallback OnChanged { get; set; } + + private string _newAlias = string.Empty; + + private async Task OnKeyDown(KeyboardEventArgs args) + { + if (args.Key == "Enter") await AddAsync(); + } + + private async Task AddAsync() + { + var alias = _newAlias.Trim(); + if (alias.Length == 0) return; + + if (!Values.Contains(alias, StringComparer.OrdinalIgnoreCase)) + { + Values.Add(alias); + await OnChanged.InvokeAsync(); + } + + _newAlias = string.Empty; + } + + private async Task RemoveAsync(string alias) + { + var existing = Values.FirstOrDefault(value => + value.Equals(alias, StringComparison.OrdinalIgnoreCase)); + if (existing is null) return; + + Values.Remove(existing); + await OnChanged.InvokeAsync(); + } +} diff --git a/src/Components/SettingsDialog.razor b/src/Components/SettingsDialog.razor index fcc66e6..86f3871 100644 --- a/src/Components/SettingsDialog.razor +++ b/src/Components/SettingsDialog.razor @@ -23,6 +23,60 @@
+
+ + + Correlação de eventos + Aliases equivalentes a CorrelationId + + + +
+

+ TraceId, SpanId e RequestId são reconhecidos sempre. Os campos abaixo são + tratados como o mesmo CorrelationId, mesmo quando aplicações diferentes usam + nomes distintos. +

+ +
+
+ +
+ + + Integração OpenTelemetry / Seq + Aliases para campos personalizados + + + +
+

+ Os campos padrão Seq/CLEF e os campos OTLP presentes nos eventos são + reconhecidos automaticamente. Configure aliases somente quando sua aplicação + usar outros nomes de propriedades. +

+ + + + +
+
+
Ignorar Linhas de Log (Contém Texto)

Ex: HealthCheck

@@ -118,6 +172,10 @@ SettingsService.Save(); } + private void SaveObservability() => SettingsService.Save(); + + private void SaveCorrelation() => SettingsService.Save(); + private void SetAsDefaultApp() { FileAssociationService.SetAsDefault(); diff --git a/src/Helpers/FormatacaoTempo.cs b/src/Helpers/FormatacaoTempo.cs new file mode 100644 index 0000000..7d6b080 --- /dev/null +++ b/src/Helpers/FormatacaoTempo.cs @@ -0,0 +1,33 @@ +namespace ClefExplorer.Helpers +{ + /// Formatação compacta de intervalos usados nos diagnósticos temporais. + public static class FormatacaoTempo + { + public static string Intervalo(TimeSpan intervalo) + { + if (intervalo < TimeSpan.Zero) intervalo = intervalo.Negate(); + + if (intervalo.TotalDays >= 1) + return intervalo.Hours == 0 + ? $"{(int)intervalo.TotalDays} d" + : $"{(int)intervalo.TotalDays} d {intervalo.Hours} h"; + if (intervalo.TotalHours >= 1) + return intervalo.Minutes == 0 + ? $"{(int)intervalo.TotalHours} h" + : $"{(int)intervalo.TotalHours} h {intervalo.Minutes} min"; + if (intervalo.TotalMinutes >= 1) + return intervalo.Seconds == 0 + ? $"{(int)intervalo.TotalMinutes} min" + : $"{(int)intervalo.TotalMinutes} min {intervalo.Seconds} s"; + if (intervalo.TotalSeconds >= 1) + return $"{intervalo.TotalSeconds:0.###} s"; + if (intervalo.TotalMilliseconds >= 1) + return $"{intervalo.TotalMilliseconds:0.###} ms"; + if (intervalo.Ticks >= 10) + return $"{intervalo.Ticks / 10d:0.###} µs"; + if (intervalo > TimeSpan.Zero) + return "< 1 µs"; + return "0 ms"; + } + } +} diff --git a/src/Models/ClefEvent.cs b/src/Models/ClefEvent.cs index 9eb4455..25f3639 100644 --- a/src/Models/ClefEvent.cs +++ b/src/Models/ClefEvent.cs @@ -12,6 +12,39 @@ public class ClefEvent public string? MessageTemplate { get; set; } public string? Exception { get; set; } public string? SourceFile { get; set; } + + /// + /// Identificador W3C do trace. No CLEF ele é transportado pelo campo reservado + /// @tr; fica fora de para não virar uma coluna + /// dinâmica comum. + /// + public string? TraceId { get; set; } + + /// + /// Identificador W3C do span. No CLEF ele é transportado pelo campo reservado + /// @sp; fica fora de pelo mesmo motivo do trace. + /// + public string? SpanId { get; set; } + + /// + /// Identificador W3C do span pai. A extensão de tracing do CLEF usa @ps + /// para preservar a hierarquia sem misturá-la às propriedades da aplicação. + /// + public string? ParentSpanId { get; set; } + + /// + /// Início real do span, quando o produtor publica o campo @st. Nesse caso, + /// representa o fim do span e a diferença entre ambos é + /// uma duração observada, não uma estimativa entre logs. + /// + public DateTimeOffset? SpanStart { get; set; } + + /// + /// Campos CLEF adicionais de OpenTelemetry/Seq (@sk, @sc e + /// @ra). Permanece nulo para eventos comuns. + /// + public MetadadosClefObservabilidade? ObservabilidadeClef { get; set; } + /// /// Propriedades estruturadas do evento. A interface (e não Dictionary) /// permite ao parser publicar a forma compacta de ; diff --git a/src/Models/MetadadosClefObservabilidade.cs b/src/Models/MetadadosClefObservabilidade.cs new file mode 100644 index 0000000..053e561 --- /dev/null +++ b/src/Models/MetadadosClefObservabilidade.cs @@ -0,0 +1,21 @@ +using Serilog.Events; + +namespace ClefExplorer.Models +{ + /// + /// Extensões CLEF de observabilidade que não são propriedades da aplicação. O objeto + /// é criado somente quando algum desses campos aparece, evitando três referências + /// adicionais em cada um dos milhões de logs comuns que o aplicativo pode carregar. + /// + public sealed class MetadadosClefObservabilidade + { + /// Kind do span transportado em @sk. + public string? TipoSpan { get; set; } + + /// Escopo de instrumentação transportado em @sc. + public LogEventPropertyValue? EscopoInstrumentacao { get; set; } + + /// Atributos do recurso transportados em @ra. + public LogEventPropertyValue? AtributosRecurso { get; set; } + } +} diff --git a/src/Models/MetadadosObservabilidadeEvento.cs b/src/Models/MetadadosObservabilidadeEvento.cs new file mode 100644 index 0000000..f2fad06 --- /dev/null +++ b/src/Models/MetadadosObservabilidadeEvento.cs @@ -0,0 +1,35 @@ +namespace ClefExplorer.Models +{ + /// Fonte que sustenta a duração apresentada para um span. + public enum OrigemDuracaoObservabilidade + { + Nenhuma, + SeqClef, + OpenTelemetryOtlp, + CampoConfigurado, + } + + /// + /// Interpretação normalizada dos campos de tracing de um evento, sem alterar as + /// propriedades originais que continuam disponíveis no painel de detalhes. + /// + public sealed record MetadadosObservabilidadeEvento( + string? TraceId, + string? SpanId, + string? ParentSpanId, + string NomeOperacao, + string? NomeServico, + string? TipoSpan, + DateTimeOffset? Inicio, + DateTimeOffset? Fim, + OrigemDuracaoObservabilidade OrigemDuracao, + string? CampoDuracao) + { + public bool EhSpan => OrigemDuracao != OrigemDuracaoObservabilidade.Nenhuma + && Inicio is not null + && Fim is not null + && Inicio <= Fim; + + public TimeSpan? Duracao => EhSpan ? Fim - Inicio : null; + } +} diff --git a/src/Models/NoHierarquiaSpan.cs b/src/Models/NoHierarquiaSpan.cs new file mode 100644 index 0000000..fddd456 --- /dev/null +++ b/src/Models/NoHierarquiaSpan.cs @@ -0,0 +1,14 @@ +namespace ClefExplorer.Models +{ + /// + /// Nó imutável da árvore do trace. Spans formam os ramos; logs que carregam o mesmo + /// SpanId ficam associados ao span correspondente como folhas selecionáveis. + /// + public sealed record NoHierarquiaSpan( + ItemAnaliseTemporalCorrelacao Item, + IReadOnlyList Filhos) + { + public bool EhSpan => Item.Metadados.EhSpan; + public string? SpanId => Item.Metadados.SpanId; + } +} diff --git a/src/Models/ResultadoAnaliseTemporalCorrelacao.cs b/src/Models/ResultadoAnaliseTemporalCorrelacao.cs new file mode 100644 index 0000000..d89dad5 --- /dev/null +++ b/src/Models/ResultadoAnaliseTemporalCorrelacao.cs @@ -0,0 +1,57 @@ +namespace ClefExplorer.Models +{ + /// Origem semântica de uma medida apresentada na linha do tempo. + public enum TipoMedicaoTemporalCorrelacao + { + /// Diferença real entre @st e @t de um span. + DuracaoRealDoSpan, + + /// + /// Duração publicada num campo configurado e autodescritivo. É posicionada na + /// régua, mas permanece distinta dos contratos nativos Seq/OTLP. + /// + DuracaoInformadaPeloProdutor, + + /// + /// Distância observada entre dois logs consecutivos. Ajuda a localizar lacunas, + /// mas não afirma que uma operação permaneceu executando durante todo o período. + /// + IntervaloAteProximoEvento, + + /// Evento pontual sem um próximo evento a partir do qual inferir intervalo. + InstanteDoEvento, + } + + /// Evento correlacionado posicionado na escala temporal compartilhada. + public sealed record ItemAnaliseTemporalCorrelacao( + EventoCorrelacionado EventoCorrelacionado, + DateTimeOffset Inicio, + DateTimeOffset Fim, + TipoMedicaoTemporalCorrelacao Tipo, + MetadadosObservabilidadeEvento Metadados) + { + public ClefEvent Evento => EventoCorrelacionado.Evento; + public TimeSpan Intervalo => Fim - Inicio; + } + + /// Resultado pronto para visualização da cadeia de eventos correlacionados. + public sealed record ResultadoAnaliseTemporalCorrelacao( + DateTimeOffset? Inicio, + DateTimeOffset? Fim, + IReadOnlyList Itens, + IReadOnlyList Hierarquia) + { + public TimeSpan IntervaloTotal => Inicio is null || Fim is null + ? TimeSpan.Zero + : Fim.Value - Inicio.Value; + + public bool TemDuracoesReais => Itens.Any( + item => item.Tipo == TipoMedicaoTemporalCorrelacao.DuracaoRealDoSpan); + + public bool TemDuracoesInformadas => Itens.Any( + item => item.Tipo == TipoMedicaoTemporalCorrelacao.DuracaoInformadaPeloProdutor); + + public bool TemIntervalosEstimados => Itens.Any( + item => item.Tipo == TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento); + } +} diff --git a/src/Models/ResultadoNavegacaoCorrelacao.cs b/src/Models/ResultadoNavegacaoCorrelacao.cs new file mode 100644 index 0000000..f9f38c4 --- /dev/null +++ b/src/Models/ResultadoNavegacaoCorrelacao.cs @@ -0,0 +1,22 @@ +namespace ClefExplorer.Models +{ + /// Um campo e valor capazes de relacionar eventos de log. + public sealed record IdentificadorCorrelacao(string Campo, string Valor); + + /// + /// Evento encontrado e os identificadores da origem que justificam sua presença na + /// sequência de correlação. + /// + public sealed record EventoCorrelacionado( + ClefEvent Evento, + IReadOnlyList Correspondencias); + + /// Sequência cronológica relacionada diretamente ao evento de origem. + public sealed record ResultadoNavegacaoCorrelacao( + ClefEvent Origem, + IReadOnlyList Identificadores, + IReadOnlyList Eventos) + { + public int QuantidadeRelacionada => Math.Max(0, Eventos.Count - 1); + } +} diff --git a/src/Models/Settings.cs b/src/Models/Settings.cs index 47f3401..add938a 100644 --- a/src/Models/Settings.cs +++ b/src/Models/Settings.cs @@ -6,5 +6,91 @@ public class Settings { public List IgnoredFilePatterns { get; set; } = new(); public List IgnoredLogLines { get; set; } = new(); + public ConfiguracaoCorrelacao Correlacao { get; set; } = new(); + public ConfiguracaoObservabilidade Observabilidade { get; set; } = new(); + + public void Normalizar() + { + IgnoredFilePatterns ??= new(); + IgnoredLogLines ??= new(); + Correlacao ??= new(); + Observabilidade ??= new(); + Correlacao.Normalizar(); + Observabilidade.Normalizar(); + } + } + + /// + /// Nomes de propriedades que representam o mesmo identificador lógico de correlação. + /// TraceId, SpanId e RequestId são contratos próprios e continuam reconhecidos + /// independentemente desta lista. + /// + public sealed class ConfiguracaoCorrelacao + { + public List Campos { get; set; } = + [ + "X-Correlation-Id", + "CorrelationId", + ]; + + public void Normalizar() => Campos = NormalizarCampos(Campos); + + private static List NormalizarCampos(List? campos) => (campos ?? new()) + .Where(campo => !string.IsNullOrWhiteSpace(campo)) + .Select(campo => campo.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Aliases usados para interpretar propriedades estruturadas de tracing sem impor + /// nomes específicos da aplicação aos arquivos do usuário. + /// + public sealed class ConfiguracaoObservabilidade + { + public List CamposNomeOperacao { get; set; } = + [ + "OperationName", + "SpanName", + "ActivityName", + "otel.span.name", + ]; + + public List CamposNomeServico { get; set; } = + [ + "ServiceName", + "Application", + "service.name", + "otel.service.name", + "Resource.service.name", + "@Resource.service.name", + ]; + + public List CamposTipoSpan { get; set; } = + [ + "ActivityKind", + "SpanKind", + "otel.span.kind", + ]; + + public List CamposDuracao { get; set; } = + [ + "Duration", + "Elapsed", + ]; + + public void Normalizar() + { + CamposNomeOperacao = Normalizar(CamposNomeOperacao); + CamposNomeServico = Normalizar(CamposNomeServico); + CamposTipoSpan = Normalizar(CamposTipoSpan); + CamposDuracao = Normalizar(CamposDuracao); + } + + private static List Normalizar(List? campos) => (campos ?? new()) + .Where(campo => !string.IsNullOrWhiteSpace(campo)) + .Select(campo => campo.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); } } diff --git a/src/Program.cs b/src/Program.cs index b3d3fc5..bc8577e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -53,6 +53,9 @@ static void Main(string[] args) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Services/AnaliseTemporalCorrelacao.cs b/src/Services/AnaliseTemporalCorrelacao.cs new file mode 100644 index 0000000..58be845 --- /dev/null +++ b/src/Services/AnaliseTemporalCorrelacao.cs @@ -0,0 +1,195 @@ +using ClefExplorer.Models; + +namespace ClefExplorer.Services +{ + /// + /// Classifica durações, intervalos e relações pai/filho sem atribuir semântica de + /// tracing a eventos que carregam apenas um timestamp pontual. + /// + public sealed class AnaliseTemporalCorrelacao + { + private readonly LeituraMetadadosObservabilidade _leituraMetadados; + + public AnaliseTemporalCorrelacao() + : this(new LeituraMetadadosObservabilidade()) + { + } + + public AnaliseTemporalCorrelacao(LeituraMetadadosObservabilidade leituraMetadados) + { + _leituraMetadados = leituraMetadados; + } + + public ResultadoAnaliseTemporalCorrelacao Analisar( + ResultadoNavegacaoCorrelacao resultado, + ConfiguracaoObservabilidade? configuracao = null) + { + ArgumentNullException.ThrowIfNull(resultado); + + var eventos = resultado.Eventos + .Select((item, indice) => new EventoOrdenado( + item, + indice, + _leituraMetadados.Extrair(item.Evento, configuracao))) + .Where(item => item.Evento.Evento.Timestamp is not null) + .OrderBy(item => item.Evento.Evento.Timestamp) + .ThenBy(item => item.Indice) + .ToArray(); + + if (eventos.Length == 0) + { + return new ResultadoAnaliseTemporalCorrelacao( + null, + null, + Array.Empty(), + Array.Empty()); + } + + var itens = new ItemAnaliseTemporalCorrelacao[eventos.Length]; + for (var i = 0; i < eventos.Length; i++) + { + var atual = eventos[i]; + var instante = atual.Evento.Evento.Timestamp!.Value; + + if (atual.Metadados.EhSpan) + { + itens[i] = new ItemAnaliseTemporalCorrelacao( + atual.Evento, + atual.Metadados.Inicio!.Value, + atual.Metadados.Fim!.Value, + atual.Metadados.OrigemDuracao == OrigemDuracaoObservabilidade.CampoConfigurado + ? TipoMedicaoTemporalCorrelacao.DuracaoInformadaPeloProdutor + : TipoMedicaoTemporalCorrelacao.DuracaoRealDoSpan, + atual.Metadados); + continue; + } + + if (i + 1 < eventos.Length) + { + itens[i] = new ItemAnaliseTemporalCorrelacao( + atual.Evento, + instante, + eventos[i + 1].Evento.Evento.Timestamp!.Value, + TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento, + atual.Metadados); + continue; + } + + itens[i] = new ItemAnaliseTemporalCorrelacao( + atual.Evento, + instante, + instante, + TipoMedicaoTemporalCorrelacao.InstanteDoEvento, + atual.Metadados); + } + + return new ResultadoAnaliseTemporalCorrelacao( + itens.Min(item => item.Inicio), + itens.Max(item => item.Fim), + itens, + ConstruirHierarquia(itens)); + } + + private static IReadOnlyList ConstruirHierarquia( + IReadOnlyList itens) + { + var nos = itens + .Select((item, indice) => new NoMutavel(item, indice)) + .ToArray(); + var spans = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var no in nos) + { + var spanId = no.Item.Metadados.SpanId; + if (no.Item.Metadados.EhSpan && !string.IsNullOrWhiteSpace(spanId)) + { + spans.TryAdd(spanId, no); + } + } + + foreach (var no in nos) + { + NoMutavel? pai = null; + var spanId = no.Item.Metadados.SpanId; + var ehSpanPrincipal = !string.IsNullOrWhiteSpace(spanId) + && spans.TryGetValue(spanId, out var principal) + && ReferenceEquals(principal, no); + + if (ehSpanPrincipal) + { + var parentSpanId = no.Item.Metadados.ParentSpanId; + if (!string.IsNullOrWhiteSpace(parentSpanId)) + { + spans.TryGetValue(parentSpanId, out pai); + } + } + else if (!string.IsNullOrWhiteSpace(spanId)) + { + // Um log produzido dentro de uma Activity carrega o SpanId atual. O + // evento de término do span é o ramo; os demais ficam como suas folhas. + spans.TryGetValue(spanId, out pai); + } + + if (pai is null || ReferenceEquals(pai, no) || CriariaCiclo(no, pai)) continue; + no.Pai = pai; + pai.Filhos.Add(no); + } + + foreach (var no in nos) + { + no.Filhos.Sort(CompararNos); + } + + return nos + .Where(no => no.Pai is null) + .OrderBy(no => no, Comparer.Create(CompararNos)) + .Select(Congelar) + .ToArray(); + } + + private static bool CriariaCiclo(NoMutavel filho, NoMutavel pai) + { + for (var atual = pai; atual is not null; atual = atual.Pai) + { + if (ReferenceEquals(atual, filho)) return true; + } + + return false; + } + + private static int CompararNos(NoMutavel esquerdo, NoMutavel direito) + { + var inicioEsquerdo = esquerdo.Item.Metadados.Inicio + ?? esquerdo.Item.Evento.Timestamp + ?? DateTimeOffset.MaxValue; + var inicioDireito = direito.Item.Metadados.Inicio + ?? direito.Item.Evento.Timestamp + ?? DateTimeOffset.MaxValue; + var comparacao = inicioEsquerdo.CompareTo(inicioDireito); + return comparacao != 0 ? comparacao : esquerdo.Ordem.CompareTo(direito.Ordem); + } + + private static NoHierarquiaSpan Congelar(NoMutavel no) => new( + no.Item, + no.Filhos.Select(Congelar).ToArray()); + + private sealed record EventoOrdenado( + EventoCorrelacionado Evento, + int Indice, + MetadadosObservabilidadeEvento Metadados); + + private sealed class NoMutavel + { + public NoMutavel(ItemAnaliseTemporalCorrelacao item, int ordem) + { + Item = item; + Ordem = ordem; + } + + public ItemAnaliseTemporalCorrelacao Item { get; } + public int Ordem { get; } + public NoMutavel? Pai { get; set; } + public List Filhos { get; } = new(); + } + } +} diff --git a/src/Services/LeitorClef.cs b/src/Services/LeitorClef.cs index df19521..dca4caa 100644 --- a/src/Services/LeitorClef.cs +++ b/src/Services/LeitorClef.cs @@ -307,6 +307,13 @@ private static ClefEvent Ler(ReadOnlySpan linha, string arquivo, CacheDeTe string? mensagemPronta = null; string? nivel = null; string? excecao = null; + string? traceId = null; + string? spanId = null; + string? parentSpanId = null; + DateTimeOffset? spanStart = null; + string? spanKind = null; + LogEventPropertyValue? instrumentationScope = null; + LogEventPropertyValue? resourceAttributes = null; object? eventId = null; var temEventId = false; List? propriedades = null; @@ -318,7 +325,7 @@ private static ClefEvent Ler(ReadOnlySpan linha, string arquivo, CacheDeTe { if (reader.ValueTextEquals("@t"u8)) { - timestamp = LerTimestamp(ref reader); + timestamp = LerTimestamp(ref reader, "@t", obrigatorio: true); } else if (reader.ValueTextEquals("@mt"u8)) { @@ -346,15 +353,38 @@ private static ClefEvent Ler(ReadOnlySpan linha, string arquivo, CacheDeTe } else if (reader.ValueTextEquals("@tr"u8)) { - // O ClefEvent não guarda trace/span, mas a validação precisa continuar - // valendo: um @tr corrompido derruba a linha no leitor oficial. - var tr = LerTexto(ref reader, "@tr"); - if (tr != null) ActivityTraceId.CreateFromString(tr.AsSpan()); + traceId = LerTexto(ref reader, "@tr"); + // Além de guardar o texto original, preservamos a validação do + // leitor oficial: um identificador corrompido invalida a linha. + if (traceId != null) ActivityTraceId.CreateFromString(traceId.AsSpan()); + } + else if (reader.ValueTextEquals("@sp"u8)) + { + spanId = LerTexto(ref reader, "@sp"); + if (spanId != null) ActivitySpanId.CreateFromString(spanId.AsSpan()); + } + else if (reader.ValueTextEquals("@ps"u8)) + { + parentSpanId = LerTexto(ref reader, "@ps"); + if (parentSpanId != null) ActivitySpanId.CreateFromString(parentSpanId.AsSpan()); + } + else if (reader.ValueTextEquals("@st"u8)) + { + spanStart = LerTimestamp(ref reader, "@st", obrigatorio: false); + } + else if (reader.ValueTextEquals("@sk"u8)) + { + spanKind = LerTexto(ref reader, "@sk"); + } + else if (reader.ValueTextEquals("@sc"u8)) + { + reader.Read(); + instrumentationScope = LerValor(ref reader, rascunho, cache); } else { - var sp = LerTexto(ref reader, "@sp"); - if (sp != null) ActivitySpanId.CreateFromString(sp.AsSpan()); + reader.Read(); + resourceAttributes = LerValor(ref reader, rascunho, cache); } continue; @@ -402,6 +432,20 @@ private static ClefEvent Ler(ReadOnlySpan linha, string arquivo, CacheDeTe : template.Template.Render(new PropriedadesOrdinais(propriedades), CultureInfo.InvariantCulture), Exception = excecao, SourceFile = arquivo, + TraceId = traceId, + SpanId = spanId, + ParentSpanId = parentSpanId, + SpanStart = spanStart, + ObservabilidadeClef = spanKind is null + && instrumentationScope is null + && resourceAttributes is null + ? null + : new MetadadosClefObservabilidade + { + TipoSpan = spanKind, + EscopoInstrumentacao = instrumentationScope, + AtributosRecurso = resourceAttributes, + }, // A forma compacta em vez de Dictionary: 18 pares imutáveis não precisam // de buckets, e multiplicado por centenas de milhares de eventos o // dicionário era uma das maiores fatias da memória retida. @@ -414,7 +458,7 @@ private static ClefEvent Ler(ReadOnlySpan linha, string arquivo, CacheDeTe } /// - /// Só vale a pena comparar com os nove nomes reservados quando o nome começa com '@'. + /// Só vale a pena comparar com os quatorze nomes reservados quando o nome começa com '@'. /// Nome escapado ("@t") entra na comparação porque o ValueTextEquals desescapa. /// private static bool EhReservado(ref Utf8JsonReader reader) @@ -433,7 +477,12 @@ private static bool EhReservado(ref Utf8JsonReader reader) || reader.ValueTextEquals("@r"u8) || reader.ValueTextEquals("@i"u8) || reader.ValueTextEquals("@tr"u8) - || reader.ValueTextEquals("@sp"u8); + || reader.ValueTextEquals("@sp"u8) + || reader.ValueTextEquals("@ps"u8) + || reader.ValueTextEquals("@st"u8) + || reader.ValueTextEquals("@sk"u8) + || reader.ValueTextEquals("@sc"u8) + || reader.ValueTextEquals("@ra"u8); } private static void Acrescentar( @@ -758,17 +807,21 @@ private static object LerNumero(ref Utf8JsonReader reader) private static int TamanhoEmBytes(ref Utf8JsonReader reader) => reader.HasValueSequence ? (int)reader.ValueSequence.Length : reader.ValueSpan.Length; - private static DateTimeOffset LerTimestamp(ref Utf8JsonReader reader) + private static DateTimeOffset? LerTimestamp( + ref Utf8JsonReader reader, + string campo, + bool obrigatorio) { reader.Read(); if (reader.TokenType != JsonTokenType.String) { if (reader.TokenType == JsonTokenType.Null) { - throw new InvalidDataException("A linha não inclui o campo obrigatório `@t`."); + if (!obrigatorio) return null; + throw new InvalidDataException($"A linha não inclui o campo obrigatório `{campo}`."); } - throw new InvalidDataException("O valor de `@t` não está num formato suportado."); + throw new InvalidDataException($"O valor de `{campo}` não está num formato suportado."); } if (!reader.ValueIsEscaped && !reader.HasValueSequence && TentarTimestampCanonico(reader.ValueSpan, out var canonico)) @@ -780,7 +833,7 @@ private static DateTimeOffset LerTimestamp(ref Utf8JsonReader reader) // provider). Trocar por InvariantCulture aqui mudaria quais textos são aceitos. if (!DateTimeOffset.TryParse(reader.GetString(), out var valor)) { - throw new InvalidDataException("O valor de `@t` não está num formato de data suportado."); + throw new InvalidDataException($"O valor de `{campo}` não está num formato de data suportado."); } return valor; diff --git a/src/Services/LeituraMetadadosObservabilidade.cs b/src/Services/LeituraMetadadosObservabilidade.cs new file mode 100644 index 0000000..d17d0ad --- /dev/null +++ b/src/Services/LeituraMetadadosObservabilidade.cs @@ -0,0 +1,349 @@ +using System.Globalization; +using System.Numerics; +using System.Xml; +using ClefExplorer.Models; +using Serilog.Events; + +namespace ClefExplorer.Services +{ + /// + /// Converte extensões Seq/CLEF e campos OTLP conhecidos em metadados comuns. Aliases + /// configuráveis só são usados para nomes e durações autodescritivas; números sem + /// unidade nunca viram duração por inferência. + /// + public sealed class LeituraMetadadosObservabilidade + { + private static readonly DateTimeOffset UnixEpoch = DateTimeOffset.UnixEpoch; + + public MetadadosObservabilidadeEvento Extrair( + ClefEvent evento, + ConfiguracaoObservabilidade? configuracao = null) + { + ArgumentNullException.ThrowIfNull(evento); + configuracao ??= new ConfiguracaoObservabilidade(); + + var valores = new List(evento.Properties?.Count + 8 ?? 8); + if (evento.ObservabilidadeClef?.AtributosRecurso is { } recurso) + { + Enumerar(recurso, "@Resource", valores, 0); + } + if (evento.ObservabilidadeClef?.EscopoInstrumentacao is { } escopo) + { + Enumerar(escopo, "@Scope", valores, 0); + } + if (evento.Properties is not null) + { + foreach (var propriedade in evento.Properties) + { + Enumerar(propriedade.Value, propriedade.Key, valores, 0); + } + } + + var traceId = PrimeiroTexto(evento.TraceId, EncontrarTexto(valores, "traceId")); + var spanId = PrimeiroTexto(evento.SpanId, EncontrarTexto(valores, "spanId")); + var parentSpanId = PrimeiroTexto( + evento.ParentSpanId, + EncontrarTexto(valores, "parentSpanId"), + EncontrarTexto(valores, "parentId")); + + var inicioOtlp = Encontrar(valores, "startTimeUnixNano"); + var fimOtlp = Encontrar(valores, "endTimeUnixNano"); + var inicioOtlpConvertido = default(DateTimeOffset); + var fimOtlpConvertido = default(DateTimeOffset); + var ehOtlp = inicioOtlp is not null + && fimOtlp is not null + && TentarTimestampUnixNano(inicioOtlp.Value.Valor, out inicioOtlpConvertido) + && TentarTimestampUnixNano(fimOtlp.Value.Valor, out fimOtlpConvertido) + && inicioOtlpConvertido <= fimOtlpConvertido; + + var nomeExplicito = ehOtlp + ? EncontrarTexto(valores, "name") + : null; + nomeExplicito ??= EncontrarTexto(valores, configuracao.CamposNomeOperacao); + + var tipoSpan = PrimeiroTexto( + evento.ObservabilidadeClef?.TipoSpan, + ehOtlp ? FormatarSpanKindOtlp(EncontrarEscalar(valores, "kind")) : null, + EncontrarTexto(valores, configuracao.CamposTipoSpan)); + + var nomeServico = EncontrarTexto(valores, configuracao.CamposNomeServico); + var nomeOperacao = PrimeiroTexto( + nomeExplicito, + evento.MessageTemplate, + evento.Message, + "(operação sem nome)")!; + + if (evento.SpanStart is { } inicioSeq + && evento.Timestamp is { } fimSeq + && inicioSeq <= fimSeq) + { + return new MetadadosObservabilidadeEvento( + traceId, + spanId, + parentSpanId, + nomeOperacao, + nomeServico, + tipoSpan, + inicioSeq, + fimSeq, + OrigemDuracaoObservabilidade.SeqClef, + "@st"); + } + + if (ehOtlp) + { + return new MetadadosObservabilidadeEvento( + traceId, + spanId, + parentSpanId, + nomeOperacao, + nomeServico, + tipoSpan, + inicioOtlpConvertido, + fimOtlpConvertido, + OrigemDuracaoObservabilidade.OpenTelemetryOtlp, + "startTimeUnixNano/endTimeUnixNano"); + } + + // Uma duração configurada só identifica um span quando há contexto explícito + // (nome/kind + SpanId) e o próprio valor carrega unidade. "Elapsed: 42" é + // deliberadamente ignorado: poderia significar ticks, ms, segundos ou contagem. + var duracaoInformada = Encontrar(valores, configuracao.CamposDuracao); + if (!string.IsNullOrWhiteSpace(spanId) + && evento.Timestamp is { } fimInformado + && (nomeExplicito is not null || tipoSpan is not null) + && duracaoInformada is { } valorDuracao + && TentarDuracaoAutodescritiva(valorDuracao.Valor, out var duracao) + && duracao >= TimeSpan.Zero + && duracao <= fimInformado - DateTimeOffset.MinValue) + { + return new MetadadosObservabilidadeEvento( + traceId, + spanId, + parentSpanId, + nomeOperacao, + nomeServico, + tipoSpan, + fimInformado - duracao, + fimInformado, + OrigemDuracaoObservabilidade.CampoConfigurado, + valorDuracao.Caminho); + } + + return new MetadadosObservabilidadeEvento( + traceId, + spanId, + parentSpanId, + nomeOperacao, + nomeServico, + tipoSpan, + null, + null, + OrigemDuracaoObservabilidade.Nenhuma, + null); + } + + private static void Enumerar( + LogEventPropertyValue valor, + string caminho, + List destino, + int profundidade) + { + if (profundidade > 16) return; + var nome = caminho[(caminho.LastIndexOf('.') + 1)..]; + destino.Add(new ValorNomeado(nome, caminho, valor)); + + switch (valor) + { + case StructureValue estrutura: + foreach (var propriedade in estrutura.Properties) + { + Enumerar( + propriedade.Value, + $"{caminho}.{propriedade.Name}", + destino, + profundidade + 1); + } + break; + + case DictionaryValue dicionario: + foreach (var par in dicionario.Elements) + { + if (par.Key is ScalarValue { Value: string chave }) + { + Enumerar(par.Value, $"{caminho}.{chave}", destino, profundidade + 1); + } + } + break; + } + } + + private static ValorNomeado? Encontrar( + IReadOnlyList valores, + string alias) => Encontrar(valores, new[] { alias }); + + private static ValorNomeado? Encontrar( + IReadOnlyList valores, + IEnumerable aliases) + { + foreach (var alias in aliases) + { + if (string.IsNullOrWhiteSpace(alias)) continue; + foreach (var valor in valores) + { + if (valor.Nome.Equals(alias, StringComparison.OrdinalIgnoreCase) + || valor.Caminho.Equals(alias, StringComparison.OrdinalIgnoreCase)) + { + return valor; + } + } + } + + return null; + } + + private static string? EncontrarTexto( + IReadOnlyList valores, + string alias) => Texto(Encontrar(valores, alias)?.Valor); + + private static string? EncontrarTexto( + IReadOnlyList valores, + IEnumerable aliases) => Texto(Encontrar(valores, aliases)?.Valor); + + private static object? EncontrarEscalar( + IReadOnlyList valores, + string alias) => (Encontrar(valores, alias)?.Valor as ScalarValue)?.Value; + + private static string? Texto(LogEventPropertyValue? valor) => valor is ScalarValue escalar + ? Formatar(escalar.Value) + : null; + + private static string? Formatar(object? valor) => valor switch + { + null => null, + string texto when !string.IsNullOrWhiteSpace(texto) => texto.Trim(), + IFormattable formatavel => formatavel.ToString(null, CultureInfo.InvariantCulture), + _ => valor.ToString(), + }; + + private static string? PrimeiroTexto(params string?[] valores) => valores + .FirstOrDefault(valor => !string.IsNullOrWhiteSpace(valor)) + ?.Trim(); + + private static string? FormatarSpanKindOtlp(object? valor) + { + var texto = Formatar(valor); + return texto switch + { + "0" => "Unspecified", + "1" => "Internal", + "2" => "Server", + "3" => "Client", + "4" => "Producer", + "5" => "Consumer", + _ => texto, + }; + } + + private static bool TentarTimestampUnixNano( + LogEventPropertyValue valor, + out DateTimeOffset timestamp) + { + timestamp = default; + if (valor is not ScalarValue escalar) return false; + var texto = Formatar(escalar.Value); + if (texto is null + || !BigInteger.TryParse(texto, NumberStyles.Integer, CultureInfo.InvariantCulture, out var nanos) + || nanos < BigInteger.Zero) + { + return false; + } + + var ticks = nanos / 100; + var maximo = new BigInteger(DateTimeOffset.MaxValue.UtcTicks - UnixEpoch.UtcTicks); + if (ticks > maximo) return false; + + timestamp = new DateTimeOffset(UnixEpoch.UtcTicks + (long)ticks, TimeSpan.Zero); + return true; + } + + private static bool TentarDuracaoAutodescritiva( + LogEventPropertyValue valor, + out TimeSpan duracao) + { + duracao = default; + if (valor is not ScalarValue escalar) return false; + if (escalar.Value is TimeSpan timeSpan) + { + duracao = timeSpan; + return true; + } + if (escalar.Value is not string texto || string.IsNullOrWhiteSpace(texto)) return false; + + texto = texto.Trim(); + if (texto.Contains(':') + && TimeSpan.TryParse(texto, CultureInfo.InvariantCulture, out duracao)) + { + return true; + } + + if (texto.StartsWith("P", StringComparison.OrdinalIgnoreCase)) + { + try + { + duracao = XmlConvert.ToTimeSpan(texto); + return true; + } + catch (FormatException) + { + return false; + } + } + + var unidades = new (string Sufixo, Func Converter)[] + { + ("ticks", valor => TimeSpan.FromTicks((long)valor)), + ("ns", valor => TimeSpan.FromTicks((long)(valor / 100d))), + ("µs", valor => TimeSpan.FromTicks((long)(valor * 10d))), + ("us", valor => TimeSpan.FromTicks((long)(valor * 10d))), + ("ms", TimeSpan.FromMilliseconds), + ("min", TimeSpan.FromMinutes), + ("s", TimeSpan.FromSeconds), + ("h", TimeSpan.FromHours), + }; + + foreach (var (sufixo, converter) in unidades) + { + if (!texto.EndsWith(sufixo, StringComparison.OrdinalIgnoreCase)) continue; + var numero = texto[..^sufixo.Length].Trim(); + if (!double.TryParse( + numero, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var valorNumerico) + || !double.IsFinite(valorNumerico) + || valorNumerico < 0) + { + return false; + } + + try + { + duracao = converter(valorNumerico); + return true; + } + catch (OverflowException) + { + return false; + } + } + + return false; + } + + private readonly record struct ValorNomeado( + string Nome, + string Caminho, + LogEventPropertyValue Valor); + } +} diff --git a/src/Services/LogExporter.cs b/src/Services/LogExporter.cs index bd23881..0ac546a 100644 --- a/src/Services/LogExporter.cs +++ b/src/Services/LogExporter.cs @@ -303,6 +303,50 @@ private static string ClefLine(ClefEvent evento, CacheDeTemplates cache) WriteJsonProperty(writer, "@x", new ScalarValue(evento.Exception), ref primeiro); } + // Trace/span são campos reservados do CLEF, não propriedades comuns. Gravá-los + // explicitamente mantém a navegação por correlação depois de exportar e reabrir. + if (!string.IsNullOrWhiteSpace(evento.TraceId)) + { + WriteJsonProperty(writer, "@tr", new ScalarValue(evento.TraceId), ref primeiro); + } + + if (!string.IsNullOrWhiteSpace(evento.SpanId)) + { + WriteJsonProperty(writer, "@sp", new ScalarValue(evento.SpanId), ref primeiro); + } + + if (!string.IsNullOrWhiteSpace(evento.ParentSpanId)) + { + WriteJsonProperty(writer, "@ps", new ScalarValue(evento.ParentSpanId), ref primeiro); + } + + if (evento.SpanStart is { } inicioDoSpan) + { + WriteJsonProperty( + writer, + "@st", + new ScalarValue(inicioDoSpan.ToString("O", CultureInfo.InvariantCulture)), + ref primeiro); + } + + if (evento.ObservabilidadeClef is { } observabilidade) + { + if (!string.IsNullOrWhiteSpace(observabilidade.TipoSpan)) + { + WriteJsonProperty(writer, "@sk", new ScalarValue(observabilidade.TipoSpan), ref primeiro); + } + + if (observabilidade.EscopoInstrumentacao is { } escopo) + { + WriteJsonProperty(writer, "@sc", escopo, ref primeiro); + } + + if (observabilidade.AtributosRecurso is { } recurso) + { + WriteJsonProperty(writer, "@ra", recurso, ref primeiro); + } + } + if (evento.Properties is not null) { foreach (var propriedade in evento.Properties) diff --git a/src/Services/NavegacaoCorrelacao.cs b/src/Services/NavegacaoCorrelacao.cs new file mode 100644 index 0000000..b5b39f4 --- /dev/null +++ b/src/Services/NavegacaoCorrelacao.cs @@ -0,0 +1,374 @@ +using System.Globalization; +using ClefExplorer.Models; +using Serilog.Events; + +namespace ClefExplorer.Services +{ + /// + /// Descobre eventos relacionados por identificadores de rastreamento sem alterar os + /// filtros da consulta atual. A relação é direta: um evento entra quando compartilha + /// identificador lógico e valor com o evento de origem. + /// + public sealed class NavegacaoCorrelacao + { + private const string CampoTraceId = "TraceId"; + private const string CampoSpanId = "SpanId"; + private const string CampoRequestId = "RequestId"; + private const string CampoCorrelationId = "CorrelationId"; + + private static readonly ComparadorIdentificador Comparador = new(); + private readonly ConfiguracaoCorrelacao _configuracao; + + public NavegacaoCorrelacao() + : this(new ConfiguracaoCorrelacao()) + { + } + + public NavegacaoCorrelacao(SettingsService settingsService) + : this(settingsService?.Settings.Correlacao + ?? throw new ArgumentNullException(nameof(settingsService))) + { + } + + public NavegacaoCorrelacao(ConfiguracaoCorrelacao configuracao) + { + _configuracao = configuracao + ?? throw new ArgumentNullException(nameof(configuracao)); + } + + /// Indica se o evento oferece ao menos uma chave navegável. + public bool PodeNavegar(ClefEvent? evento) + { + if (evento is null) return false; + var identificadores = new List(4); + ExtrairPara(evento, identificadores, CapturarCamposCorrelacao()); + return identificadores.Count > 0; + } + + /// + /// Indica se a propriedade é um dos aliases configurados para o identificador + /// lógico CorrelationId. + /// + public bool EhCampoCorrelacao(string nome) => + Canonicalizar(nome, CapturarCamposCorrelacao()) is { EhCorrelacao: true }; + + /// + /// Localiza, em todos os eventos carregados, os que compartilham ao menos um dos + /// identificadores da origem. Tipo lógico e valor precisam coincidir; um RequestId + /// não é confundido com um TraceId de texto igual. + /// + public ResultadoNavegacaoCorrelacao Localizar( + ClefEvent origem, + IEnumerable eventos, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(origem); + ArgumentNullException.ThrowIfNull(eventos); + + var camposCorrelacao = CapturarCamposCorrelacao(); + var identificadores = ExtrairIdentificadores(origem, camposCorrelacao); + if (identificadores.Count == 0) + { + return new ResultadoNavegacaoCorrelacao( + origem, + identificadores, + Array.Empty()); + } + + var procurados = new HashSet(identificadores, Comparador); + var encontrados = new List<(EventoCorrelacionado Evento, int Ordem)>(); + // Reutilizado durante a varredura: num conjunto com 1 milhão de eventos, criar + // uma lista vazia para cada linha geraria dezenas de MB de lixo só para concluir + // que a maioria não tem nenhuma das quatro chaves. + var extraidos = new List(8); + var origemEncontrada = false; + var ordem = 0; + + foreach (var evento in eventos) + { + cancellationToken.ThrowIfCancellationRequested(); + + extraidos.Clear(); + ExtrairPara(evento, extraidos, camposCorrelacao); + List? correspondencias = null; + foreach (var identificador in extraidos) + { + if (!procurados.Contains(identificador) + || correspondencias?.Contains(identificador, Comparador) == true) + { + continue; + } + + (correspondencias ??= new List(2)).Add(identificador); + } + + if (correspondencias is not null) + { + encontrados.Add((new EventoCorrelacionado(evento, correspondencias.ToArray()), ordem)); + if (ReferenceEquals(evento, origem)) origemEncontrada = true; + } + + ordem++; + } + + // O chamador normalmente usa o snapshot do Store, mas manter a origem garante + // um resultado coerente também para integrações que passam uma amostra parcial. + if (!origemEncontrada) + { + encontrados.Add((new EventoCorrelacionado(origem, identificadores), ordem)); + } + + var sequencia = encontrados + .OrderBy(item => item.Evento.Evento.Timestamp is null) + .ThenBy(item => item.Evento.Evento.Timestamp) + .ThenBy(item => item.Ordem) + .Select(item => item.Evento) + .ToArray(); + + return new ResultadoNavegacaoCorrelacao(origem, identificadores, sequencia); + } + + /// + /// Extrai as quatro chaves aceitas. Trace/span reservados vêm do modelo; as + /// propriedades também são percorridas dentro de estruturas, sequências e mapas. + /// + public IReadOnlyList ExtrairIdentificadores(ClefEvent evento) + { + ArgumentNullException.ThrowIfNull(evento); + return ExtrairIdentificadores(evento, CapturarCamposCorrelacao()); + } + + private static IReadOnlyList ExtrairIdentificadores( + ClefEvent evento, + IReadOnlyList camposCorrelacao) + { + var resultado = new List(4); + ExtrairPara(evento, resultado, camposCorrelacao); + return resultado.Distinct(Comparador).ToArray(); + } + + private static void ExtrairPara( + ClefEvent evento, + List resultado, + IReadOnlyList camposCorrelacao) + { + Adicionar(resultado, CampoTraceId, evento.TraceId, separarValores: false); + Adicionar(resultado, CampoSpanId, evento.SpanId, separarValores: false); + + if (evento.Properties is not null) + { + foreach (var propriedade in evento.Properties) + { + ExtrairDaPropriedade( + propriedade.Key, + propriedade.Value, + resultado, + camposCorrelacao); + } + } + } + + private static void ExtrairDaPropriedade( + string nome, + LogEventPropertyValue valor, + List destino, + IReadOnlyList camposCorrelacao) + { + var campo = Canonicalizar(nome, camposCorrelacao); + if (campo is { } reconhecido) + { + ExtrairValoresDoCampo(reconhecido, valor, destino); + } + + // Mesmo quando o contêiner tem um nome reconhecido, seus filhos podem trazer + // outros identificadores e também precisam ser visitados. + switch (valor) + { + case StructureValue estrutura: + foreach (var propriedade in estrutura.Properties) + { + ExtrairDaPropriedade( + propriedade.Name, + propriedade.Value, + destino, + camposCorrelacao); + } + break; + + case SequenceValue sequencia: + foreach (var item in sequencia.Elements) + { + ExtrairDeValorAninhado(item, destino, camposCorrelacao); + } + break; + + case DictionaryValue dicionario: + foreach (var par in dicionario.Elements) + { + if (par.Key is ScalarValue { Value: string chave }) + { + ExtrairDaPropriedade(chave, par.Value, destino, camposCorrelacao); + } + else + { + ExtrairDeValorAninhado(par.Value, destino, camposCorrelacao); + } + } + break; + } + } + + private static void ExtrairDeValorAninhado( + LogEventPropertyValue valor, + List destino, + IReadOnlyList camposCorrelacao) + { + switch (valor) + { + case StructureValue estrutura: + foreach (var propriedade in estrutura.Properties) + { + ExtrairDaPropriedade( + propriedade.Name, + propriedade.Value, + destino, + camposCorrelacao); + } + break; + + case SequenceValue sequencia: + foreach (var item in sequencia.Elements) + { + ExtrairDeValorAninhado(item, destino, camposCorrelacao); + } + break; + + case DictionaryValue dicionario: + foreach (var par in dicionario.Elements) + { + if (par.Key is ScalarValue { Value: string chave }) + { + ExtrairDaPropriedade(chave, par.Value, destino, camposCorrelacao); + } + else + { + ExtrairDeValorAninhado(par.Value, destino, camposCorrelacao); + } + } + break; + } + } + + private static void ExtrairValoresDoCampo( + CampoReconhecido campo, + LogEventPropertyValue valor, + List destino) + { + switch (valor) + { + case ScalarValue escalar: + Adicionar( + destino, + campo.Nome, + Formatar(escalar.Value), + separarValores: campo.SepararValores); + break; + + case SequenceValue sequencia: + foreach (var item in sequencia.Elements) + { + if (item is ScalarValue escalarDoItem) + { + Adicionar( + destino, + campo.Nome, + Formatar(escalarDoItem.Value), + separarValores: campo.SepararValores); + } + } + break; + } + } + + private static CampoReconhecido? Canonicalizar( + string nome, + IReadOnlyList camposCorrelacao) + { + if (nome.Equals(CampoTraceId, StringComparison.OrdinalIgnoreCase)) + return new CampoReconhecido(CampoTraceId, EhCorrelacao: false, SepararValores: false); + if (nome.Equals(CampoSpanId, StringComparison.OrdinalIgnoreCase)) + return new CampoReconhecido(CampoSpanId, EhCorrelacao: false, SepararValores: false); + if (nome.Equals(CampoRequestId, StringComparison.OrdinalIgnoreCase)) + return new CampoReconhecido(CampoRequestId, EhCorrelacao: false, SepararValores: false); + + foreach (var alias in camposCorrelacao) + { + if (nome.Equals(alias, StringComparison.OrdinalIgnoreCase)) + return new CampoReconhecido( + CampoCorrelationId, + EhCorrelacao: true, + SepararValores: nome.Equals( + "X-Correlation-Id", + StringComparison.OrdinalIgnoreCase)); + } + + return null; + } + + private string[] CapturarCamposCorrelacao() => (_configuracao.Campos ?? new()) + .Where(campo => !string.IsNullOrWhiteSpace(campo)) + .Select(campo => campo.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string? Formatar(object? valor) => valor switch + { + null => null, + string texto => texto, + IFormattable formatavel => formatavel.ToString(null, CultureInfo.InvariantCulture), + _ => valor.ToString(), + }; + + private static void Adicionar( + List destino, + string campo, + string? valor, + bool separarValores) + { + if (string.IsNullOrWhiteSpace(valor)) return; + + // X-Correlation-Id pode chegar como cabeçalho HTTP concatenado. Os demais + // identificadores permanecem opacos e nunca são divididos por inferência. + var partes = separarValores + ? valor.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : new[] { valor.Trim() }; + + foreach (var parte in partes) + { + if (!string.IsNullOrWhiteSpace(parte)) + { + destino.Add(new IdentificadorCorrelacao(campo, parte)); + } + } + } + + private readonly record struct CampoReconhecido( + string Nome, + bool EhCorrelacao, + bool SepararValores); + + private sealed class ComparadorIdentificador : IEqualityComparer + { + public bool Equals(IdentificadorCorrelacao? x, IdentificadorCorrelacao? y) => + ReferenceEquals(x, y) + || x is not null && y is not null + && StringComparer.OrdinalIgnoreCase.Equals(x.Campo, y.Campo) + && StringComparer.OrdinalIgnoreCase.Equals(x.Valor, y.Valor); + + public int GetHashCode(IdentificadorCorrelacao obj) => + HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Campo), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Valor)); + } + } +} diff --git a/src/Services/SettingsService.cs b/src/Services/SettingsService.cs index 7b62e02..0e5b423 100644 --- a/src/Services/SettingsService.cs +++ b/src/Services/SettingsService.cs @@ -28,6 +28,7 @@ public void Save() { try { + _settings.Normalizar(); var json = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true }); _storage.WriteText(FileName, json); LastError = null; @@ -48,6 +49,7 @@ private void LoadSettings() if (json is null) return; _settings = JsonSerializer.Deserialize(json) ?? new Settings(); + _settings.Normalizar(); LastError = null; } catch (Exception ex) diff --git a/src/wwwroot/css/app.css b/src/wwwroot/css/app.css index 92b58c2..88baf00 100644 --- a/src/wwwroot/css/app.css +++ b/src/wwwroot/css/app.css @@ -335,11 +335,27 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } background: var(--omni-bg-sunken); } +.clef-list-item.is-correlated { + background: color-mix(in oklab, var(--omni-info) 10%, var(--omni-bg)); + border-left-color: color-mix(in oklab, var(--omni-info) 78%, var(--omni-fg)); +} + +.clef-list-item.is-correlated:hover { + background: color-mix(in oklab, var(--omni-info) 15%, var(--omni-bg)); +} + .clef-list-item.is-selected { background: var(--omni-accent-soft); border-left-color: var(--omni-accent); } +/* A seleção continua roxa; a barra azul preserva a informação independente de + que a mesma linha também pertence à sequência correlacionada. */ +.clef-list-item.is-correlated.is-selected { + background: var(--omni-accent-soft); + border-left-color: color-mix(in oklab, var(--omni-info) 78%, var(--omni-fg)); +} + .clef-list-item-body { flex: 1 1 auto; min-width: 0; @@ -434,6 +450,14 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } gap: 5px; } +.clef-detail-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex-shrink: 0; +} + .clef-detail-card { margin-bottom: 16px; } @@ -533,6 +557,605 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } gap: 6px; } +/* ---- Navegação por correlação -------------------------------------------- */ +.clef-correlation-view { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.clef-correlation-title { + margin: 0; + color: var(--omni-fg); + font-size: 15px; + font-weight: 700; +} + +.clef-correlation-subtitle { + margin-top: 2px; + color: var(--omni-fg-muted); + font-size: 12.5px; +} + +.clef-correlation-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.clef-correlation-popover { + width: 100%; + padding: 13px; + color: var(--omni-fg); +} + +.clef-correlation-popover-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + color: var(--omni-fg); +} + +.clef-correlation-popover-head h3 { + margin: 0; + font-size: 13px; + font-weight: 700; +} + +.clef-correlation-popover p { + margin: 0 0 10px; + color: var(--omni-fg-muted); + font-size: 12px; + line-height: 1.45; +} + +.clef-correlation-popover .clef-correlation-popover-note { + padding-top: 9px; + margin: 10px 0 0; + border-top: 1px solid var(--omni-line); +} + +.clef-correlation-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.clef-correlation-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + padding: 4px 7px; + border: 1px solid var(--omni-line); + border-radius: var(--omni-radius-sm); + background: var(--omni-bg-sunken); + color: var(--omni-fg); + font-family: var(--omni-font-mono); + font-size: 11.5px; +} + +.clef-correlation-chip strong { + color: var(--omni-fg-muted); + font-family: var(--omni-font); + font-size: 11px; +} + +.clef-correlation-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clef-correlation-list { + flex: 1; + min-height: 0; + overflow-y: auto; + border-top: 1px solid var(--omni-line); +} + +.clef-correlation-tabs { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.clef-correlation-tabs > .omni-tabs-bar { + flex-shrink: 0; + padding-inline: 8px; +} + +.clef-correlation-tabs > .omni-tabs-bar .omni-tab { + padding: 7px 10px; + font-size: 12.5px; +} + +.clef-correlation-tabs > .omni-tabs-body { + display: flex; + min-height: 0; + flex: 1; + padding: 0; + overflow: hidden; +} + +.clef-correlation-event { + position: relative; + display: flex; + width: 100%; + min-height: 60px; + padding: 7px 10px 7px 22px; + border: 0; + border-bottom: 1px solid var(--omni-line); + background: transparent; + color: var(--omni-fg); + font: inherit; + text-align: left; + cursor: pointer; +} + +.clef-correlation-event:hover:not(:disabled) { + background: var(--omni-bg-hover); +} + +.clef-correlation-event:focus-visible { + outline: 2px solid var(--omni-accent); + outline-offset: -2px; +} + +.clef-correlation-event.is-current { + background: color-mix(in oklab, var(--omni-accent) 10%, transparent); + cursor: default; + opacity: 1; +} + +.clef-correlation-rail { + position: absolute; + top: 0; + bottom: 0; + left: 9px; + width: 1px; + background: var(--omni-line-strong); +} + +.clef-correlation-rail::before { + position: absolute; + top: 15px; + left: -4px; + width: 9px; + height: 9px; + border: 2px solid var(--omni-bg-sunken); + border-radius: 50%; + background: var(--omni-fg-muted); + content: ""; +} + +.clef-correlation-event.is-current .clef-correlation-rail::before { + background: var(--omni-accent); +} + +.clef-correlation-event-body { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.clef-correlation-event-head { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.clef-correlation-time { + flex-shrink: 0; + color: var(--omni-fg-muted); + font-family: var(--omni-font-mono); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} + +.clef-correlation-source { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 4px; + overflow: hidden; + color: var(--omni-fg-muted); + font-size: 11px; +} + +.clef-correlation-source > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clef-correlation-flags { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; +} + +.clef-correlation-origin { + color: var(--omni-fg-muted); +} + +.clef-correlation-current { + color: var(--omni-accent); +} + +.clef-correlation-message { + display: block; + overflow: hidden; + color: var(--omni-fg); + font-family: var(--omni-font-mono); + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clef-correlation-empty { + flex: 1; + min-height: 160px; +} + +/* ---- Tempos da correlação ------------------------------------------------ */ +.clef-correlation-timing { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.clef-correlation-timing-summary { + display: flex; + align-items: center; + gap: 8px 14px; + padding: 8px 10px; + border-bottom: 1px solid var(--omni-line); + background: var(--omni-bg-sunken); + color: var(--omni-fg-muted); + font-size: 11px; + line-height: 1.35; + flex-wrap: wrap; +} + +.clef-correlation-timing-total { + display: inline-flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; +} + +.clef-correlation-timing-total strong { + color: var(--omni-fg); + font-family: var(--omni-font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.clef-correlation-timing-legend { + display: flex; + align-items: center; + gap: 7px 12px; + flex-wrap: wrap; +} + +.clef-correlation-timing-legend > span { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} + +.clef-correlation-timing-legend i { + display: inline-block; + width: 14px; + height: 5px; + border-radius: 999px; +} + +.clef-correlation-timing-legend i.is-real { + background: var(--omni-accent); +} + +.clef-correlation-timing-legend i.is-reported { + border: 1px solid color-mix(in oklab, var(--omni-warn) 72%, var(--omni-fg)); + background: color-mix(in oklab, var(--omni-warn) 24%, transparent); +} + +.clef-correlation-timing-legend i.is-estimated { + border: 1px dashed color-mix(in oklab, var(--omni-info) 72%, var(--omni-fg)); + background: color-mix(in oklab, var(--omni-info) 18%, transparent); +} + +.clef-correlation-timing-legend i.is-point { + width: 5px; + background: var(--omni-fg-muted); +} + +.clef-correlation-timing-caveat { + flex-basis: 100%; +} + +.clef-correlation-timing-scroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.clef-correlation-timing-grid { + min-width: 680px; +} + +.clef-correlation-timing-scale, +.clef-correlation-timing-row { + display: grid; + grid-template-columns: minmax(250px, 40%) minmax(360px, 1fr); +} + +.clef-correlation-timing-scale { + position: sticky; + z-index: 2; + top: 0; + min-height: 30px; + align-items: end; + border-bottom: 1px solid var(--omni-line-strong); + background: var(--omni-bg-elev); + color: var(--omni-fg-muted); + font-size: 10.5px; +} + +.clef-correlation-timing-scale > span:first-child { + padding: 0 10px 6px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; +} + +.clef-correlation-timing-scale-track { + position: relative; + height: 100%; + margin-inline: 12px 18px; +} + +.clef-correlation-timing-scale-track > span { + position: absolute; + bottom: 6px; + font-family: var(--omni-font-mono); + font-variant-numeric: tabular-nums; + transform: translateX(-50%); + white-space: nowrap; +} + +.clef-correlation-timing-scale-track > span:first-child { + transform: none; +} + +.clef-correlation-timing-row { + width: 100%; + min-height: 54px; + padding: 0; + border: 0; + border-bottom: 1px solid var(--omni-line); + background: transparent; + color: var(--omni-fg); + font: inherit; + text-align: left; + cursor: pointer; +} + +.clef-correlation-timing-row:hover { + background: var(--omni-bg-hover); +} + +.clef-correlation-timing-row:focus-visible { + z-index: 1; + outline: 2px solid var(--omni-accent); + outline-offset: -2px; +} + +.clef-correlation-timing-row.is-current { + background: color-mix(in oklab, var(--omni-accent) 10%, transparent); + cursor: default; + opacity: 1; +} + +.clef-correlation-timing-event { + display: flex; + min-width: 0; + align-self: stretch; + align-items: center; + gap: 4px; + padding: 6px 10px 6px calc(7px + var(--clef-tree-depth, 0) * 16px); + border-right: 1px solid var(--omni-line); +} + +.clef-correlation-tree-toggle, +.clef-correlation-tree-spacer, +.clef-correlation-tree-kind { + display: inline-flex; + width: 20px; + height: 24px; + flex: 0 0 20px; + align-items: center; + justify-content: center; +} + +.clef-correlation-tree-toggle { + padding: 0; + border: 0; + border-radius: var(--omni-radius-sm); + background: transparent; + color: var(--omni-fg-muted); +} + +.clef-correlation-tree-toggle:hover { + background: var(--omni-bg-sunken); + color: var(--omni-fg); +} + +.clef-correlation-tree-kind { + color: var(--omni-fg-muted); +} + +.clef-correlation-timing-row.is-span .clef-correlation-tree-kind { + color: color-mix(in oklab, var(--omni-accent) 62%, var(--omni-fg)); +} + +.clef-correlation-timing-event-content { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.clef-correlation-timing-event-head { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.clef-correlation-timing-duration { + margin-left: auto; + border-radius: 999px; + padding: 1px 5px; + font-family: var(--omni-font-mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.clef-correlation-timing-duration.is-real { + background: color-mix(in oklab, var(--omni-accent) 12%, transparent); + color: color-mix(in oklab, var(--omni-accent) 65%, var(--omni-fg)); +} + +.clef-correlation-timing-duration.is-reported { + background: color-mix(in oklab, var(--omni-warn) 12%, transparent); + color: color-mix(in oklab, var(--omni-warn) 66%, var(--omni-fg)); +} + +.clef-correlation-timing-duration.is-estimated { + background: color-mix(in oklab, var(--omni-info) 10%, transparent); + color: color-mix(in oklab, var(--omni-info) 65%, var(--omni-fg)); +} + +.clef-correlation-timing-duration.is-point { + color: var(--omni-fg-muted); +} + +.clef-correlation-observability-tag, +.clef-correlation-service { + overflow: hidden; + border-radius: 999px; + padding: 1px 5px; + color: var(--omni-fg-muted); + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.clef-correlation-observability-tag { + border: 1px solid var(--omni-line); + text-transform: uppercase; +} + +.clef-correlation-service { + max-width: 110px; + background: var(--omni-bg-sunken); +} + +.clef-correlation-timing-row.is-span .clef-correlation-message { + font-weight: 650; +} + +.clef-correlation-timing-track { + position: relative; + min-width: 0; + align-self: stretch; + margin-inline: 12px 18px; + background-image: linear-gradient(to right, var(--omni-line) 1px, transparent 1px); + background-position: left top; + background-size: 25% 100%; +} + +.clef-correlation-timing-track::after { + position: absolute; + top: 50%; + right: 0; + left: 0; + height: 1px; + background: var(--omni-line-strong); + content: ""; +} + +.clef-correlation-timing-bar { + position: absolute; + z-index: 1; + top: 50%; + left: var(--clef-time-start); + width: max(3px, var(--clef-time-width)); + height: 12px; + border-radius: 3px; + transform: translateY(-50%); +} + +.clef-correlation-timing-bar.is-real { + background: var(--omni-accent); +} + +.clef-correlation-timing-bar.is-reported { + border: 1px solid color-mix(in oklab, var(--omni-warn) 76%, var(--omni-fg)); + background: color-mix(in oklab, var(--omni-warn) 28%, transparent); +} + +.clef-correlation-timing-bar.is-estimated { + border: 1px dashed color-mix(in oklab, var(--omni-info) 76%, var(--omni-fg)); + background: repeating-linear-gradient( + 135deg, + color-mix(in oklab, var(--omni-info) 28%, transparent) 0 4px, + color-mix(in oklab, var(--omni-info) 8%, transparent) 4px 8px); +} + +.clef-correlation-timing-marker { + position: absolute; + z-index: 2; + top: 50%; + left: var(--clef-time-start); + width: 3px; + height: 18px; + border-radius: 999px; + background: var(--omni-fg-muted); + transform: translate(-50%, -50%); +} + +.clef-correlation-timing-marker.is-real { + background: color-mix(in oklab, var(--omni-accent) 72%, var(--omni-fg)); +} + +.clef-correlation-timing-marker.is-reported { + background: color-mix(in oklab, var(--omni-warn) 72%, var(--omni-fg)); +} + +.clef-correlation-timing-marker.is-estimated { + background: color-mix(in oklab, var(--omni-info) 72%, var(--omni-fg)); +} + /* ---- Stack trace destacado (StackTraceHighlighter) ------------------------ */ /* Cores cromáticas (info/accent/danger) como texto pequeno reprovam AA sobre fundos escuros/claros; misturar com --omni-fg adapta a luminância por tema. */ @@ -715,10 +1338,125 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; } } /* Linha do evento selecionado: mesma leitura da lista. */ +.clef-grid .omni-grid tbody tr.is-correlated { + background: color-mix(in oklab, var(--omni-info) 10%, var(--omni-bg)); +} +.clef-dialog-disclosure { + border: 1px solid var(--omni-line); + border-radius: var(--omni-radius-md); + background: color-mix(in oklab, var(--omni-bg-elev) 82%, transparent); +} +.clef-dialog-disclosure > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + cursor: pointer; + list-style: none; +} +.clef-dialog-disclosure > summary::-webkit-details-marker { display: none; } +.clef-dialog-disclosure > summary span { + display: flex; + flex-direction: column; + gap: 2px; +} +.clef-dialog-disclosure > summary strong { + font-size: 13.5px; + color: var(--omni-fg); +} +.clef-dialog-disclosure > summary small { + font-size: 11.5px; + font-weight: 400; + color: var(--omni-fg-muted); +} +.clef-dialog-disclosure > summary .omni-icon { transition: transform 120ms ease; } +.clef-dialog-disclosure[open] > summary .omni-icon { transform: rotate(180deg); } +.clef-dialog-disclosure-content { + padding: 0 12px 12px; + border-top: 1px solid var(--omni-line); +} +.clef-dialog-disclosure-content > .clef-dialog-hint { margin-top: 10px; } +.clef-observability-alias-group + .clef-observability-alias-group { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid color-mix(in oklab, var(--omni-line) 70%, transparent); +} +.clef-observability-alias-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} +.clef-observability-alias-heading label { + font-size: 12.5px; + font-weight: 650; + color: var(--omni-fg); +} +.clef-observability-alias-heading span { + font-size: 11px; + color: var(--omni-fg-muted); + text-align: right; +} +.clef-observability-aliases { + display: flex; + flex-wrap: wrap; + gap: 5px; +} +.clef-observability-alias { + display: inline-flex; + align-items: center; + gap: 3px; + min-width: 0; + padding: 2px 3px 2px 7px; + border: 1px solid var(--omni-line); + border-radius: 999px; + background: var(--omni-bg); + color: var(--omni-fg); +} +.clef-observability-alias code { font-size: 11.5px; } +.clef-observability-alias button { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--omni-fg-muted); + cursor: pointer; +} +.clef-observability-alias button:hover { + background: color-mix(in oklab, var(--omni-danger) 12%, transparent); + color: color-mix(in oklab, var(--omni-danger) 78%, var(--omni-fg)); +} +.clef-observability-alias-input { + margin: 7px 0 0; +} +.clef-observability-origin { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in oklab, var(--omni-primary) 9%, transparent); + color: var(--omni-fg-muted); + font-size: 10.5px; +} + +.clef-grid .omni-grid tbody tr.is-correlated td:first-child { + box-shadow: inset 3px 0 0 0 color-mix(in oklab, var(--omni-info) 78%, var(--omni-fg)); +} + .clef-grid .omni-grid tbody tr.is-selected { background: color-mix(in oklab, var(--omni-accent) 14%, var(--omni-bg)); } +.clef-grid .omni-grid tbody tr.is-correlated.is-selected { + background: color-mix(in oklab, var(--omni-accent) 14%, var(--omni-bg)); +} + /* Barra à esquerda em erros/fatais, como na lista — cor, e não só o badge, para o erro saltar mesmo com a coluna Nível oculta. */ .clef-grid .omni-grid tbody tr.clef-grid-row-error td:first-child { diff --git a/test/ClefExplorer.Tests/AnaliseTemporalCorrelacaoTests.cs b/test/ClefExplorer.Tests/AnaliseTemporalCorrelacaoTests.cs new file mode 100644 index 0000000..8024c04 --- /dev/null +++ b/test/ClefExplorer.Tests/AnaliseTemporalCorrelacaoTests.cs @@ -0,0 +1,157 @@ +using ClefExplorer.Models; +using ClefExplorer.Services; + +namespace ClefExplorer.Tests; + +public class AnaliseTemporalCorrelacaoTests +{ + private readonly AnaliseTemporalCorrelacao _analise = new(); + + [Fact] + public void Usa_inicio_e_fim_reais_quando_o_evento_e_um_span() + { + var span = Evento(3, inicioDoSpan: 1); + + var resultado = _analise.Analisar(Resultado(span)); + + var item = Assert.Single(resultado.Itens); + Assert.Equal(TipoMedicaoTemporalCorrelacao.DuracaoRealDoSpan, item.Tipo); + Assert.Equal(TimeSpan.FromSeconds(2), item.Intervalo); + Assert.Equal(span.SpanStart, resultado.Inicio); + Assert.Equal(span.Timestamp, resultado.Fim); + Assert.True(resultado.TemDuracoesReais); + Assert.False(resultado.TemIntervalosEstimados); + } + + [Fact] + public void Log_comum_mede_apenas_o_intervalo_ate_o_proximo_evento() + { + var primeiro = Evento(1); + var segundo = Evento(4); + + var resultado = _analise.Analisar(Resultado(primeiro, segundo)); + + Assert.Equal(TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento, resultado.Itens[0].Tipo); + Assert.Equal(TimeSpan.FromSeconds(3), resultado.Itens[0].Intervalo); + Assert.Equal(TipoMedicaoTemporalCorrelacao.InstanteDoEvento, resultado.Itens[1].Tipo); + Assert.Equal(TimeSpan.Zero, resultado.Itens[1].Intervalo); + Assert.True(resultado.TemIntervalosEstimados); + Assert.False(resultado.TemDuracoesReais); + } + + [Fact] + public void Ordena_eventos_e_ignora_os_que_nao_tem_instante() + { + var ultimo = Evento(5); + var semInstante = Evento(3); + semInstante.Timestamp = null; + var primeiro = Evento(1); + + var resultado = _analise.Analisar(Resultado(ultimo, semInstante, primeiro)); + + Assert.Equal(new[] { primeiro, ultimo }, resultado.Itens.Select(item => item.Evento)); + Assert.Equal(TimeSpan.FromSeconds(4), resultado.IntervaloTotal); + } + + [Fact] + public void Inicio_de_span_invalido_nao_e_apresentado_como_duracao_real() + { + var invalido = Evento(1, inicioDoSpan: 2); + var proximo = Evento(3); + + var resultado = _analise.Analisar(Resultado(invalido, proximo)); + + Assert.Equal(TipoMedicaoTemporalCorrelacao.IntervaloAteProximoEvento, resultado.Itens[0].Tipo); + Assert.Equal(TimeSpan.FromSeconds(2), resultado.Itens[0].Intervalo); + } + + [Fact] + public void Mantem_ordem_original_quando_os_instantes_sao_iguais() + { + var primeiro = Evento(1); + var segundo = Evento(1); + + var resultado = _analise.Analisar(Resultado(primeiro, segundo)); + + Assert.Equal(new[] { primeiro, segundo }, resultado.Itens.Select(item => item.Evento)); + } + + [Fact] + public void Monta_arvore_de_spans_e_anexa_logs_ao_span_atual() + { + var raiz = Evento(5, inicioDoSpan: 0); + raiz.MessageTemplate = "POST /pedidos"; + raiz.SpanId = "1111111111111111"; + + var filho = Evento(3, inicioDoSpan: 1); + filho.MessageTemplate = "INSERT pedidos"; + filho.SpanId = "2222222222222222"; + filho.ParentSpanId = raiz.SpanId; + + var logDoFilho = Evento(2); + logDoFilho.Message = "Executando comando"; + logDoFilho.SpanId = filho.SpanId; + + var resultado = _analise.Analisar(Resultado(raiz, filho, logDoFilho)); + + var noRaiz = Assert.Single(resultado.Hierarquia); + Assert.Same(raiz, noRaiz.Item.Evento); + var noFilho = Assert.Single(noRaiz.Filhos); + Assert.Same(filho, noFilho.Item.Evento); + Assert.Same(logDoFilho, Assert.Single(noFilho.Filhos).Item.Evento); + } + + [Fact] + public void Mantem_spans_orfaos_como_raizes() + { + var orfao = Evento(3, inicioDoSpan: 1); + orfao.SpanId = "2222222222222222"; + orfao.ParentSpanId = "9999999999999999"; + + var resultado = _analise.Analisar(Resultado(orfao)); + + Assert.Same(orfao, Assert.Single(resultado.Hierarquia).Item.Evento); + } + + [Fact] + public void Ciclo_em_parent_span_id_nao_remove_eventos_nem_recursa_indefinidamente() + { + var primeiro = Evento(3, inicioDoSpan: 1); + primeiro.SpanId = "1111111111111111"; + primeiro.ParentSpanId = "2222222222222222"; + var segundo = Evento(4, inicioDoSpan: 2); + segundo.SpanId = "2222222222222222"; + segundo.ParentSpanId = "1111111111111111"; + + var resultado = _analise.Analisar(Resultado(primeiro, segundo)); + + static int Contar(IEnumerable nos) => + nos.Sum(no => 1 + Contar(no.Filhos)); + Assert.Equal(2, Contar(resultado.Hierarquia)); + } + + private static ResultadoNavegacaoCorrelacao Resultado(params ClefEvent[] eventos) + { + var correlacionados = eventos + .Select(evento => new EventoCorrelacionado( + evento, + new[] { new IdentificadorCorrelacao("TraceId", "trace") })) + .ToArray(); + + return new ResultadoNavegacaoCorrelacao( + eventos[0], + new[] { new IdentificadorCorrelacao("TraceId", "trace") }, + correlacionados); + } + + private static ClefEvent Evento(int segundo, int? inicioDoSpan = null) => new() + { + Timestamp = Instante(segundo), + SpanStart = inicioDoSpan is null ? null : Instante(inicioDoSpan.Value), + Level = "Information", + Message = $"evento {segundo}", + }; + + private static DateTimeOffset Instante(int segundo) => + new(2026, 8, 5, 10, 0, segundo, TimeSpan.Zero); +} diff --git a/test/ClefExplorer.Tests/LeitorClefTests.cs b/test/ClefExplorer.Tests/LeitorClefTests.cs index e51a4c4..c31176e 100644 --- a/test/ClefExplorer.Tests/LeitorClefTests.cs +++ b/test/ClefExplorer.Tests/LeitorClefTests.cs @@ -278,7 +278,7 @@ public void The_level_follows_the_enum_parsing_rules(string trecho, string esper public void An_unknown_level_invalidates_the_line(string trecho) => Invalida("{" + Instante + "," + trecho + "}"); - // --- BLOCO D: @x, @tr, @sp, @i ---------------------------------------------- + // --- BLOCO D: @x, @tr, @sp, @ps, @st, @i ------------------------------------ [Fact] public void The_exception_keeps_the_raw_text_with_its_line_breaks() @@ -296,15 +296,55 @@ public void The_exception_keeps_the_raw_text_with_its_line_breaks() [InlineData(@"""@tr"":{""a"":1}")] [InlineData(@"""@sp"":true")] [InlineData(@"""@sp"":""b7ad""")] + [InlineData(@"""@ps"":""b7ad""")] + [InlineData(@"""@st"":true")] + [InlineData(@"""@st"":""data inválida""")] + [InlineData(@"""@sk"":{""kind"":""Server""}")] + [InlineData(@"""@sk"":42")] public void A_malformed_reserved_field_invalidates_the_line(string trecho) => Invalida("{" + Instante + @",""@mt"":""x""," + trecho + "}"); [Fact] - public void Valid_trace_and_span_ids_do_not_become_properties() + public void Identificadores_validos_de_trace_e_span_sao_preservados_fora_das_propriedades() { - // O ClefEvent não guarda trace/span; o que não pode acontecer é eles virarem coluna. var evento = Ler(@"{" + Instante + @",""@mt"":""x"",""@tr"":""0af7651916cd43dd8448eb211c80319c"",""@sp"":""b7ad6b7169203331""}"); + Assert.Equal("0af7651916cd43dd8448eb211c80319c", evento.TraceId); + Assert.Equal("b7ad6b7169203331", evento.SpanId); + Assert.Empty(evento.Properties!); + } + + [Fact] + public void Metadados_de_span_sao_preservados_fora_das_propriedades() + { + var evento = Ler(@"{" + Instante + + @",""@mt"":""x"",""@ps"":""00f067aa0ba902b7"",""@st"":""2026-07-31T22:44:15.9504192-03:00""}"); + + Assert.Equal("00f067aa0ba902b7", evento.ParentSpanId); + Assert.Equal( + new DateTimeOffset(2026, 7, 31, 22, 44, 15, TimeSpan.FromHours(-3)).AddTicks(9504192), + evento.SpanStart); + Assert.Empty(evento.Properties!); + } + + [Fact] + public void Extensoes_de_observabilidade_Seq_sao_preservadas_fora_das_propriedades() + { + var evento = Ler(@"{" + Instante + + @",""@mt"":""GET /pedidos"",""@sk"":""Server"",""@sc"":{""name"":""OpenTelemetry.Instrumentation.AspNetCore"",""version"":""1.12.0""},""@ra"":{""service.name"":""pedidos-api"",""service.version"":""2.4.0""}}" ); + + var observabilidade = Assert.IsType(evento.ObservabilidadeClef); + Assert.Equal("Server", observabilidade.TipoSpan); + + var escopo = Assert.IsType(observabilidade.EscopoInstrumentacao); + Assert.Equal( + "OpenTelemetry.Instrumentation.AspNetCore", + Assert.IsType(escopo.Properties.Single(p => p.Name == "name").Value).Value); + + var recurso = Assert.IsType(observabilidade.AtributosRecurso); + Assert.Equal( + "pedidos-api", + Assert.IsType(recurso.Properties.Single(p => p.Name == "service.name").Value).Value); Assert.Empty(evento.Properties!); } diff --git a/test/ClefExplorer.Tests/LeituraMetadadosObservabilidadeTests.cs b/test/ClefExplorer.Tests/LeituraMetadadosObservabilidadeTests.cs new file mode 100644 index 0000000..c31841e --- /dev/null +++ b/test/ClefExplorer.Tests/LeituraMetadadosObservabilidadeTests.cs @@ -0,0 +1,119 @@ +using ClefExplorer.Models; +using ClefExplorer.Services; +using Serilog.Events; + +namespace ClefExplorer.Tests; + +public class LeituraMetadadosObservabilidadeTests +{ + private readonly LeituraMetadadosObservabilidade _leitura = new(); + + [Fact] + public void Le_extensoes_nativas_do_Seq_e_atributos_de_recurso() + { + var evento = Evento(); + evento.TraceId = "0af7651916cd43dd8448eb211c80319c"; + evento.SpanId = "b7ad6b7169203331"; + evento.SpanStart = evento.Timestamp!.Value - TimeSpan.FromMilliseconds(125); + evento.MessageTemplate = "GET /pedidos"; + evento.ObservabilidadeClef = new MetadadosClefObservabilidade + { + TipoSpan = "Server", + AtributosRecurso = Estrutura(("service.name", "pedidos-api")), + }; + + var metadados = _leitura.Extrair(evento); + + Assert.True(metadados.EhSpan); + Assert.Equal(OrigemDuracaoObservabilidade.SeqClef, metadados.OrigemDuracao); + Assert.Equal("GET /pedidos", metadados.NomeOperacao); + Assert.Equal("pedidos-api", metadados.NomeServico); + Assert.Equal("Server", metadados.TipoSpan); + Assert.Equal(TimeSpan.FromMilliseconds(125), metadados.Duracao); + } + + [Fact] + public void Le_span_no_formato_json_do_OTLP_sem_perder_precisao_de_ticks() + { + var evento = Evento(); + evento.Properties = new Dictionary + { + ["traceId"] = new ScalarValue("0af7651916cd43dd8448eb211c80319c"), + ["spanId"] = new ScalarValue("b7ad6b7169203331"), + ["parentSpanId"] = new ScalarValue("00f067aa0ba902b7"), + ["name"] = new ScalarValue("SELECT pedidos"), + ["kind"] = new ScalarValue(3L), + ["startTimeUnixNano"] = new ScalarValue("1000000123"), + ["endTimeUnixNano"] = new ScalarValue("1250000987"), + ["resource"] = Estrutura(("service.name", "pedidos-db")), + }; + + var metadados = _leitura.Extrair(evento); + + Assert.True(metadados.EhSpan); + Assert.Equal(OrigemDuracaoObservabilidade.OpenTelemetryOtlp, metadados.OrigemDuracao); + Assert.Equal("SELECT pedidos", metadados.NomeOperacao); + Assert.Equal("Client", metadados.TipoSpan); + Assert.Equal("pedidos-db", metadados.NomeServico); + Assert.Equal(DateTimeOffset.UnixEpoch.AddTicks(10_000_001), metadados.Inicio); + Assert.Equal(DateTimeOffset.UnixEpoch.AddTicks(12_500_009), metadados.Fim); + } + + [Fact] + public void Usa_aliases_configurados_para_logs_legados() + { + var evento = Evento(); + evento.SpanId = "b7ad6b7169203331"; + evento.Properties = new Dictionary + { + ["MinhaOperacao"] = new ScalarValue("Processar pedido"), + ["Aplicacao"] = new ScalarValue("worker-pedidos"), + ["ClasseSpan"] = new ScalarValue("Consumer"), + ["TempoTotal"] = new ScalarValue("250 ms"), + }; + var configuracao = new ConfiguracaoObservabilidade + { + CamposNomeOperacao = ["MinhaOperacao"], + CamposNomeServico = ["Aplicacao"], + CamposTipoSpan = ["ClasseSpan"], + CamposDuracao = ["TempoTotal"], + }; + + var metadados = _leitura.Extrair(evento, configuracao); + + Assert.True(metadados.EhSpan); + Assert.Equal(OrigemDuracaoObservabilidade.CampoConfigurado, metadados.OrigemDuracao); + Assert.Equal("Processar pedido", metadados.NomeOperacao); + Assert.Equal("worker-pedidos", metadados.NomeServico); + Assert.Equal("Consumer", metadados.TipoSpan); + Assert.Equal(TimeSpan.FromMilliseconds(250), metadados.Duracao); + } + + [Fact] + public void Nao_adivinha_a_unidade_de_uma_duracao_numerica() + { + var evento = Evento(); + evento.SpanId = "b7ad6b7169203331"; + evento.Properties = new Dictionary + { + ["OperationName"] = new ScalarValue("Processar pedido"), + ["Elapsed"] = new ScalarValue(250L), + }; + + var metadados = _leitura.Extrair(evento); + + Assert.False(metadados.EhSpan); + Assert.Equal(OrigemDuracaoObservabilidade.Nenhuma, metadados.OrigemDuracao); + } + + private static ClefEvent Evento() => new() + { + Timestamp = new DateTimeOffset(2026, 8, 5, 10, 0, 0, TimeSpan.Zero), + Level = "Information", + Message = "evento", + }; + + private static StructureValue Estrutura(params (string Nome, string Valor)[] propriedades) => + new(propriedades.Select(propriedade => + new LogEventProperty(propriedade.Nome, new ScalarValue(propriedade.Valor)))); +} diff --git a/test/ClefExplorer.Tests/LogExporterTests.cs b/test/ClefExplorer.Tests/LogExporterTests.cs index 087a827..ead7902 100644 --- a/test/ClefExplorer.Tests/LogExporterTests.cs +++ b/test/ClefExplorer.Tests/LogExporterTests.cs @@ -175,6 +175,45 @@ public void Clef_includes_the_exception() Assert.Contains("\"@x\"", clef); } + [Fact] + public void Clef_exporta_metadados_de_trace_e_span_como_campos_reservados() + { + var evento = Event(); + evento.TraceId = "0af7651916cd43dd8448eb211c80319c"; + evento.SpanId = "b7ad6b7169203331"; + evento.ParentSpanId = "00f067aa0ba902b7"; + evento.SpanStart = new DateTimeOffset(2026, 6, 15, 12, 30, 44, 850, TimeSpan.Zero); + + using var json = JsonDocument.Parse(LogExporter.ToClef(new[] { evento }).Trim()); + + Assert.Equal(evento.TraceId, json.RootElement.GetProperty("@tr").GetString()); + Assert.Equal(evento.SpanId, json.RootElement.GetProperty("@sp").GetString()); + Assert.Equal(evento.ParentSpanId, json.RootElement.GetProperty("@ps").GetString()); + Assert.Equal(evento.SpanStart, json.RootElement.GetProperty("@st").GetDateTimeOffset()); + } + + [Fact] + public void Clef_exporta_extensoes_de_observabilidade_Seq_como_campos_reservados() + { + var evento = Event(); + evento.ObservabilidadeClef = new MetadadosClefObservabilidade + { + TipoSpan = "Client", + EscopoInstrumentacao = new StructureValue( + new[] { new LogEventProperty("name", new ScalarValue("HttpClient")) }), + AtributosRecurso = new StructureValue( + new[] { new LogEventProperty("service.name", new ScalarValue("checkout-api")) }), + }; + + using var json = JsonDocument.Parse(LogExporter.ToClef(new[] { evento }).Trim()); + + Assert.Equal("Client", json.RootElement.GetProperty("@sk").GetString()); + Assert.Equal("HttpClient", json.RootElement.GetProperty("@sc").GetProperty("name").GetString()); + Assert.Equal( + "checkout-api", + json.RootElement.GetProperty("@ra").GetProperty("service.name").GetString()); + } + [Fact] public void Clef_keeps_accented_text_readable() { @@ -301,6 +340,40 @@ public async Task Clef_exportado_pode_ser_reaberto_com_tipos_preservados() } } + [Fact] + public async Task Clef_preserva_trace_e_span_na_ida_e_volta() + { + const string linha = """ + {"@t":"2026-06-15T12:30:45.0000000Z","@mt":"operação","@tr":"0af7651916cd43dd8448eb211c80319c","@sp":"b7ad6b7169203331"} + """; + + var (original, reaberto, _) = await IdaEVolta(linha); + + Assert.Equal(original.TraceId, reaberto.TraceId); + Assert.Equal(original.SpanId, reaberto.SpanId); + } + + [Fact] + public async Task Clef_preserva_extensoes_Seq_na_ida_e_volta() + { + const string linha = """ + {"@t":"2026-06-15T12:30:45.0000000Z","@mt":"GET /pedidos","@sk":"Server","@sc":{"name":"AspNetCore","version":"1.12.0"},"@ra":{"service.name":"pedidos-api"}} + """; + + var (original, reaberto, exportada) = await IdaEVolta(linha); + + Assert.Equal(original.ObservabilidadeClef!.TipoSpan, reaberto.ObservabilidadeClef!.TipoSpan); + Assert.Equal( + original.ObservabilidadeClef.EscopoInstrumentacao!.ToString(), + reaberto.ObservabilidadeClef.EscopoInstrumentacao!.ToString()); + Assert.Equal( + original.ObservabilidadeClef.AtributosRecurso!.ToString(), + reaberto.ObservabilidadeClef.AtributosRecurso!.ToString()); + Assert.Contains("\"@sk\"", exportada); + Assert.Contains("\"@sc\"", exportada); + Assert.Contains("\"@ra\"", exportada); + } + [Fact] public async Task Clef_preserva_a_mensagem_de_evento_com_token_formatado() { diff --git a/test/ClefExplorer.Tests/NavegacaoCorrelacaoTests.cs b/test/ClefExplorer.Tests/NavegacaoCorrelacaoTests.cs new file mode 100644 index 0000000..fb639bd --- /dev/null +++ b/test/ClefExplorer.Tests/NavegacaoCorrelacaoTests.cs @@ -0,0 +1,179 @@ +using ClefExplorer.Models; +using ClefExplorer.Services; +using Serilog.Events; + +namespace ClefExplorer.Tests; + +public class NavegacaoCorrelacaoTests +{ + private readonly NavegacaoCorrelacao _navegacao = new(); + + [Fact] + public void Extrai_trace_span_request_e_aliases_de_correlation_id() + { + var contexto = new StructureValue(new[] + { + new LogEventProperty("traceid", new ScalarValue("trace-aninhado")), + }); + var evento = Evento( + 0, + traceId: "trace-direto", + spanId: "span-direto", + ("RequestId", new ScalarValue("req-1")), + ("X-Correlation-Id", new ScalarValue("corr-1, corr-2")), + ("CorrelationId", new ScalarValue("corr-3")), + ("Contexto", contexto)); + + var identificadores = _navegacao.ExtrairIdentificadores(evento); + + Assert.Collection( + identificadores, + id => Assert.Equal(new IdentificadorCorrelacao("TraceId", "trace-direto"), id), + id => Assert.Equal(new IdentificadorCorrelacao("SpanId", "span-direto"), id), + id => Assert.Equal(new IdentificadorCorrelacao("RequestId", "req-1"), id), + id => Assert.Equal(new IdentificadorCorrelacao("CorrelationId", "corr-1"), id), + id => Assert.Equal(new IdentificadorCorrelacao("CorrelationId", "corr-2"), id), + id => Assert.Equal(new IdentificadorCorrelacao("CorrelationId", "corr-3"), id), + id => Assert.Equal(new IdentificadorCorrelacao("TraceId", "trace-aninhado"), id)); + } + + [Fact] + public void Localiza_as_quatro_chaves_em_ordem_cronologica() + { + var origem = Evento( + 3, + traceId: "trace-1", + spanId: "span-1", + ("RequestId", new ScalarValue("req-1")), + ("X-Correlation-Id", new ScalarValue("corr-1"))); + + var peloTrace = Evento(1, traceId: "TRACE-1"); + var peloSpan = Evento(2, spanId: "SPAN-1"); + var peloRequest = Evento(4, ("requestid", new ScalarValue("REQ-1"))); + var peloCorrelation = Evento( + 5, + ("Contexto", new StructureValue(new[] + { + // Outro serviço usa CorrelationId sem o prefixo HTTP; os dois aliases + // representam o mesmo identificador lógico. + new LogEventProperty("correlationid", new ScalarValue("CORR-1")), + }))); + var mesmoTextoEmOutroCampo = Evento(0, ("RequestId", new ScalarValue("trace-1"))); + var semRelacao = Evento(6, traceId: "trace-2"); + + var resultado = _navegacao.Localizar( + origem, + new[] { semRelacao, peloCorrelation, origem, peloRequest, mesmoTextoEmOutroCampo, peloSpan, peloTrace }); + + Assert.Equal(4, resultado.QuantidadeRelacionada); + Assert.Equal( + new[] { peloTrace, peloSpan, origem, peloRequest, peloCorrelation }, + resultado.Eventos.Select(item => item.Evento)); + Assert.DoesNotContain(resultado.Eventos, item => ReferenceEquals(item.Evento, mesmoTextoEmOutroCampo)); + } + + [Fact] + public void Campo_personalizado_e_equivalente_aos_aliases_padrao() + { + var navegacao = new NavegacaoCorrelacao(new ConfiguracaoCorrelacao + { + Campos = ["X-Correlation-Id", "CorrelationId", "IdDaOperacao"], + }); + var origem = Evento(1, ("IdDaOperacao", new ScalarValue("corr-42"))); + var relacionado = Evento(2, ("X-Correlation-Id", new ScalarValue("CORR-42"))); + + var resultado = navegacao.Localizar(origem, new[] { origem, relacionado }); + + Assert.True(navegacao.EhCampoCorrelacao("iddaoperacao")); + Assert.Equal(new[] { origem, relacionado }, resultado.Eventos.Select(item => item.Evento)); + Assert.All( + resultado.Eventos.SelectMany(item => item.Correspondencias), + identificador => Assert.Equal("CorrelationId", identificador.Campo)); + } + + [Fact] + public void Apenas_o_cabecalho_x_correlation_id_e_separado_por_virgula() + { + var evento = Evento( + 0, + ("X-Correlation-Id", new ScalarValue("x-1, x-2")), + ("CorrelationId", new ScalarValue("correlation,com,virgulas"))); + + var identificadores = _navegacao.ExtrairIdentificadores(evento); + + Assert.Equal( + new[] { "x-1", "x-2", "correlation,com,virgulas" }, + identificadores.Select(item => item.Valor)); + } + + [Fact] + public void Remover_alias_nao_desativa_trace_span_e_request_id() + { + var navegacao = new NavegacaoCorrelacao(new ConfiguracaoCorrelacao { Campos = [] }); + var evento = Evento( + 0, + traceId: "trace-1", + spanId: "span-1", + ("RequestId", new ScalarValue("req-1")), + ("CorrelationId", new ScalarValue("corr-1"))); + + var identificadores = navegacao.ExtrairIdentificadores(evento); + + Assert.Equal(3, identificadores.Count); + Assert.DoesNotContain(identificadores, item => item.Campo == "CorrelationId"); + Assert.False(navegacao.EhCampoCorrelacao("CorrelationId")); + } + + [Fact] + public void Mantem_a_origem_quando_a_amostra_nao_a_contem() + { + var origem = Evento(2, ("RequestId", new ScalarValue("req-1"))); + var relacionado = Evento(1, ("RequestId", new ScalarValue("req-1"))); + + var resultado = _navegacao.Localizar(origem, new[] { relacionado }); + + Assert.Equal(new[] { relacionado, origem }, resultado.Eventos.Select(item => item.Evento)); + } + + [Fact] + public void Evento_sem_identificador_nao_oferece_navegacao() + { + var evento = Evento(0, ("SourceContext", new ScalarValue("Api.Pedidos"))); + + Assert.False(_navegacao.PodeNavegar(evento)); + Assert.Empty(_navegacao.Localizar(evento, new[] { evento }).Eventos); + } + + [Fact] + public void Respeita_cancelamento_durante_a_varredura() + { + var origem = Evento(0, traceId: "trace-1"); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws(() => + _navegacao.Localizar(origem, new[] { origem }, cts.Token)); + } + + private static ClefEvent Evento( + int minuto, + params (string Nome, LogEventPropertyValue Valor)[] propriedades) => + Evento(minuto, null, null, propriedades); + + private static ClefEvent Evento( + int minuto, + string? traceId = null, + string? spanId = null, + params (string Nome, LogEventPropertyValue Valor)[] propriedades) => new() + { + Timestamp = new DateTimeOffset(2026, 8, 5, 12, minuto, 0, TimeSpan.Zero), + Level = "Information", + Message = $"Evento {minuto}", + TraceId = traceId, + SpanId = spanId, + Properties = propriedades.ToDictionary( + propriedade => propriedade.Nome, + propriedade => propriedade.Valor, + StringComparer.OrdinalIgnoreCase), + }; +} diff --git a/test/ClefExplorer.Tests/PersistenceTests.cs b/test/ClefExplorer.Tests/PersistenceTests.cs index ed5eab5..84f7c9b 100644 --- a/test/ClefExplorer.Tests/PersistenceTests.cs +++ b/test/ClefExplorer.Tests/PersistenceTests.cs @@ -105,6 +105,66 @@ public void Settings_survive_a_restart() Assert.Null(reaberto.LastError); } + [Fact] + public void Aliases_de_observabilidade_sobrevivem_e_sao_normalizados() + { + var service = new SettingsService(NewStorage()); + service.Settings.Observabilidade.CamposNomeOperacao = + [" MinhaOperacao ", "minhaoperacao", ""]; + service.Settings.Observabilidade.CamposNomeServico.Add("AplicacaoLegada"); + service.Save(); + + var reaberto = new SettingsService(NewStorage()); + + Assert.Equal( + new[] { "MinhaOperacao" }, + reaberto.Settings.Observabilidade.CamposNomeOperacao); + Assert.Contains("AplicacaoLegada", reaberto.Settings.Observabilidade.CamposNomeServico); + } + + [Fact] + public void Campos_de_correlacao_sobrevivem_e_sao_normalizados() + { + var service = new SettingsService(NewStorage()); + service.Settings.Correlacao.Campos = + [" X-Correlation-Id ", "CorrelationId", "correlationid", "IdDaOperacao", ""]; + service.Save(); + + var reaberto = new SettingsService(NewStorage()); + + Assert.Equal( + new[] { "X-Correlation-Id", "CorrelationId", "IdDaOperacao" }, + reaberto.Settings.Correlacao.Campos); + } + + [Fact] + public void Configuracao_antiga_recebe_aliases_padrao_de_observabilidade() + { + File.WriteAllText( + Path.Combine(_dataFolder, "settings.json"), + """{"IgnoredFilePatterns":[],"IgnoredLogLines":[]}"""); + + var service = new SettingsService(NewStorage()); + + Assert.Contains("OperationName", service.Settings.Observabilidade.CamposNomeOperacao); + Assert.Contains("service.name", service.Settings.Observabilidade.CamposNomeServico); + Assert.Contains("Duration", service.Settings.Observabilidade.CamposDuracao); + } + + [Fact] + public void Configuracao_antiga_recebe_campos_padrao_de_correlacao() + { + File.WriteAllText( + Path.Combine(_dataFolder, "settings.json"), + """{"IgnoredFilePatterns":[],"IgnoredLogLines":[]}"""); + + var service = new SettingsService(NewStorage()); + + Assert.Equal( + new[] { "X-Correlation-Id", "CorrelationId" }, + service.Settings.Correlacao.Campos); + } + [Fact] public void Save_notifies_listeners() {