EventCounters no .NET

Este artigo se aplica a: ✔️ SDK do .NET Core 3.0 e versões posteriores

EventCounters são APIs do .NET usadas para a coleta de métricas de desempenho leves, multiplataforma e quase em tempo real. Os EventCounters foram adicionados como uma alternativa multiplataforma aos "contadores de desempenho" do .NET Framework no Windows. Neste artigo, você aprenderá o que é EventCounters, como implementá-los e como consumi-los.

O runtime do .NET e algumas bibliotecas do .NET publicam informações básicas de diagnóstico usando EventCounters do .NET Core 3.0 em diante. Além dos EventCounters fornecidos pelo runtime do .NET, você pode implementar seus próprios EventCounters. Os EventCounters podem ser usados para acompanhar várias métricas. Saiba mais sobre eles em EventCounters conhecidos no .NET

Os EventCounters fazem parte de um EventSource e são enviados por push automaticamente para ferramentas ouvintes com regularidade. Como todos os outros eventos em um EventSource, eles podem ser consumidos em processo e fora do processo por meio de EventListener e EventPipe. Este artigo se concentra nas funcionalidades multiplataforma de EventCounters e exclui intencionalmente o PerfView e o ETW (Rastreamento de Eventos para Windows), embora ambos possam ser usados com EventCounters.

Imagem do diagrama em processo e fora do processo de EventCounters

Visão geral da API EventCounter

Há duas categorias primárias de EventCounters. Alguns contadores são para valores de "taxa", como número total de exceções, número total de GCs e número total de solicitações. Outros contadores são valores de "instantâneo", como uso de heap, uso de CPU e tamanho do conjunto de trabalho. Em cada uma dessas categorias de contadores, há dois tipos de contadores que variam de acordo com a forma como eles obtêm valores. Os contadores de sondagem recuperam o valor por meio de um retorno de chamada. Nos contadores que não são de sondagem, os valores são definidos diretamente na instância do contador.

Os contadores são representados pelas seguintes implementações:

Um ouvinte de eventos especifica a duração dos intervalos de medição. No final de cada intervalo, um valor é transmitido ao ouvinte para cada contador. As implementações de um contador determinam quais APIs e cálculos são usados para produzir o valor a cada intervalo.

  • O EventCounter registra um conjunto de valores. O método EventCounter.WriteMetric adiciona um novo valor ao conjunto. A cada intervalo, um resumo estatístico do conjunto é calculado, como o mínimo, o máximo e a média. A ferramenta dotnet-counters sempre exibe o valor médio. O EventCounter é útil para descrever um conjunto distinto de operações. O uso comum pode incluir o monitoramento do tamanho médio em bytes de operações de E/S recentes ou o valor monetário médio de um conjunto de transações financeiras.

  • O IncrementingEventCounter registra um total em execução para cada intervalo de tempo. O método IncrementingEventCounter.Increment faz adições ao total. Por exemplo, se Increment() for chamado três vezes durante um intervalo com os valores 1, 2 e 5, o total em execução de 8 será relatado como o valor do contador desse intervalo. A ferramenta dotnet-counters exibirá a taxa como o total/hora registrado. O IncrementingEventCounter é útil para medir a frequência com que uma ação está ocorrendo, como o número de solicitações processadas por segundo.

  • O PollingCounter usa um retorno de chamada para determinar o valor relatado. A cada intervalo de tempo, a função de retorno de chamada fornecida pelo usuário é invocada e o valor retornado é usado como o valor do contador. Um PollingCounter pode ser usado para consultar uma métrica de uma origem externa, por exemplo, obtendo os bytes livres em um disco no momento. Ele também pode ser usado para relatar estatísticas personalizadas que podem ser calculadas sob demanda por um aplicativo. Exemplos incluem o relato do 95º percentil de latências de solicitação recentes ou a taxa de ocorrência ou de perda atual de um cache.

  • O IncrementingPollingCounter usa um retorno de chamada para determinar o valor de incremento relatado. A cada intervalo de tempo, o retorno de chamada é invocado e a diferença entre a invocação atual e a última invocação é o valor relatado. A ferramenta dotnet-counters sempre exibe a diferença como uma taxa, o valor/hora relatado. Esse contador é útil quando não é viável chamar uma API em cada ocorrência, mas é possível consultar o número total de ocorrências. Por exemplo, você pode relatar o número de bytes gravados em um arquivo por segundo, mesmo sem uma notificação sempre que um byte é gravado.

Implementar um EventSource

O código a seguir implementa um exemplo de EventSource exposto como o provedor nomeado "Sample.EventCounter.Minimal". Essa origem contém um EventCounter que representa o tempo de processamento de solicitação. Esse contador tem um nome (ou seja, seu ID exclusivo na origem) e um nome de exibição, ambos usados por ferramentas de ouvinte, como dotnet-counters.

using System.Diagnostics.Tracing;

[EventSource(Name = "Sample.EventCounter.Minimal")]
public sealed class MinimalEventCounterSource : EventSource
{
    public static readonly MinimalEventCounterSource Log = new MinimalEventCounterSource();

    private EventCounter _requestCounter;

    private MinimalEventCounterSource() =>
        _requestCounter = new EventCounter("request-time", this)
        {
            DisplayName = "Request Processing Time",
            DisplayUnits = "ms"
        };

    public void Request(string url, long elapsedMilliseconds)
    {
        WriteEvent(1, url, elapsedMilliseconds);
        _requestCounter?.WriteMetric(elapsedMilliseconds);
    }

    protected override void Dispose(bool disposing)
    {
        _requestCounter?.Dispose();
        _requestCounter = null;

        base.Dispose(disposing);
    }
}

dotnet-counters ps é usado para exibir uma lista de processos do .NET que podem ser monitorados:

dotnet-counters ps
   1398652 dotnet     C:\Program Files\dotnet\dotnet.exe
   1399072 dotnet     C:\Program Files\dotnet\dotnet.exe
   1399112 dotnet     C:\Program Files\dotnet\dotnet.exe
   1401880 dotnet     C:\Program Files\dotnet\dotnet.exe
   1400180 sample-counters C:\sample-counters\bin\Debug\netcoreapp3.1\sample-counters.exe

Passe o nome EventSource à opção --counters para começar a monitorar o contador:

dotnet-counters monitor --process-id 1400180 --counters Sample.EventCounter.Minimal

O seguinte exemplo mostra a saída do monitor:

Press p to pause, r to resume, q to quit.
    Status: Running

[Samples-EventCounterDemos-Minimal]
    Request Processing Time (ms)                            0.445

Pressione q para interromper o comando de monitoramento.

Contadores condicionais

Ao implementar um EventSource, é possível criar instâncias dos contadores contidos condicionalmente quando o método EventSource.OnEventCommand é chamado com um valor de Command igual a EventCommand.Enable. Para criar uma instância de contador com segurança somente se ele for null, use o operador de avaliação de nulo. Além disso, os métodos personalizados podem avaliar o método IsEnabled para determinar se a origem do evento atual está habilitada ou não.

using System.Diagnostics.Tracing;

[EventSource(Name = "Sample.EventCounter.Conditional")]
public sealed class ConditionalEventCounterSource : EventSource
{
    public static readonly ConditionalEventCounterSource Log = new ConditionalEventCounterSource();

    private EventCounter _requestCounter;

    private ConditionalEventCounterSource() { }

    protected override void OnEventCommand(EventCommandEventArgs args)
    {
        if (args.Command == EventCommand.Enable)
        {
            _requestCounter ??= new EventCounter("request-time", this)
            {
                DisplayName = "Request Processing Time",
                DisplayUnits = "ms"
            };
        }
    }

    public void Request(string url, float elapsedMilliseconds)
    {
        if (IsEnabled())
        {
            _requestCounter?.WriteMetric(elapsedMilliseconds);
        }
    }

    protected override void Dispose(bool disposing)
    {
        _requestCounter?.Dispose();
        _requestCounter = null;

        base.Dispose(disposing);
    }
}

Dica

Contadores condicionais são aqueles cuja instância é criada condicionalmente, ou seja, uma micro-otimização. O runtime adota esse padrão para cenários em que contadores normalmente não são usados, para salvar uma fração de um milissegundo.

Contadores de exemplo de runtime do .NET Core

Há muitas implementações de exemplo excelentes no runtime do .NET Core. Aqui está a implementação do runtime do contador que rastreia o tamanho do conjunto de trabalho do aplicativo.

var workingSetCounter = new PollingCounter(
    "working-set",
    this,
    () => (double)(Environment.WorkingSet / 1_000_000))
{
    DisplayName = "Working Set",
    DisplayUnits = "MB"
};

O PollingCounter relata a quantidade atual de memória física mapeada para o processo (conjunto de trabalho) do aplicativo, pois ele captura uma métrica em um momento no tempo. O retorno de chamada para sondar um valor é a expressão lambda fornecida, que é apenas uma chamada à API System.Environment.WorkingSet. DisplayName e DisplayUnits são propriedades opcionais que podem ser definidas para ajudar o lado do consumidor do contador a exibir o valor com mais clareza. Por exemplo, o dotnet-counters usa essas propriedades para exibir a versão mais amigável dos nomes de contadores.

Importante

As propriedades DisplayName não são localizadas.

Para o PollingCounter, e o IncrementingPollingCounter, nada mais precisa ser feito. Ambos sondam os próprios valores em um intervalo solicitado pelo consumidor.

Veja um exemplo de um contador de runtime implementado usando IncrementingPollingCounter.

var monitorContentionCounter = new IncrementingPollingCounter(
    "monitor-lock-contention-count",
    this,
    () => Monitor.LockContentionCount
)
{
    DisplayName = "Monitor Lock Contention Count",
    DisplayRateTimeScale = TimeSpan.FromSeconds(1)
};

A API IncrementingPollingCounter usa Monitor.LockContentionCount para relatar o incremento da contagem total de contenção de bloqueio. A propriedade DisplayRateTimeScale é opcional, mas quando usada pode dar uma dica de qual intervalo de tempo oferece a melhor exibição do contador. Por exemplo, a contagem de contenção de bloqueio é melhor exibida como contagem por segundo, portanto, o DisplayRateTimeScale é definido como um segundo. A taxa de exibição pode ser ajustada para diferentes tipos de contadores de taxa.

Observação

O DisplayRateTimeScalenão é usado pelos dotnet-counters, e os ouvintes de eventos não são obrigados a usá-lo.

Há mais implementações de contador a serem usadas como referência no repositório do runtime do .NET.

Simultaneidade

Dica

A API EventCounters não garante o acesso thread-safe. Quando os delegados passados às instâncias PollingCounter ou IncrementingPollingCounter são chamados por vários threads, é sua responsabilidade garantir o acesso thread-safe dos delegados.

Por exemplo, considere o seguinte EventSource para acompanhar as solicitações.

using System;
using System.Diagnostics.Tracing;

public class RequestEventSource : EventSource
{
    public static readonly RequestEventSource Log = new RequestEventSource();

    private IncrementingPollingCounter _requestRateCounter;
    private long _requestCount = 0;

    private RequestEventSource() =>
        _requestRateCounter = new IncrementingPollingCounter("request-rate", this, () => _requestCount)
        {
            DisplayName = "Request Rate",
            DisplayRateTimeScale = TimeSpan.FromSeconds(1)
        };

    public void AddRequest() => ++ _requestCount;

    protected override void Dispose(bool disposing)
    {
        _requestRateCounter?.Dispose();
        _requestRateCounter = null;

        base.Dispose(disposing);
    }
}

O método AddRequest() pode ser chamado por meio de um manipulador de solicitação e sonda o valor de RequestRateCounter no intervalo especificado pelo consumidor do contador. No entanto, o método AddRequest() pode ser chamado por vários threads ao mesmo tempo, colocando uma condição de corrida em _requestCount. Uma forma alternativa de thread-safe para incrementar o _requestCount é usar Interlocked.Increment.

public void AddRequest() => Interlocked.Increment(ref _requestCount);

Para evitar leituras interrompidas (em arquiteturas de 32 bits) do campo _requestCount de long, use Interlocked.Read.

_requestRateCounter = new IncrementingPollingCounter("request-rate", this, () => Interlocked.Read(ref _requestCount))
{
    DisplayName = "Request Rate",
    DisplayRateTimeScale = TimeSpan.FromSeconds(1)
};

Consumir EventCounters

Há duas maneiras principais de consumir EventCounters: em processo ou fora do processo. O consumo de EventCounters pode ser distinguido em três camadas de várias tecnologias de consumo.

  • Transporte de eventos em um fluxo bruto por ETW ou EventPipe:

    As APIs do ETW vêm com o sistema operacional Windows e o EventPipe é acessível como uma API do .NET ou o protocolo IPC de diagnóstico.

  • Decodificação do fluxo de eventos binários em eventos:

    A biblioteca TraceEvent manipula os formatos de fluxo ETW e EventPipe.

  • Ferramentas de GUI e de linha de comando:

    Ferramentas como PerfView (ETW ou EventPipe), dotnet-counters (somente EventPipe) e dotnet-monitor (somente EventPipe).

Consumir fora do proc

O consumo de EventCounters fora do processo é uma abordagem comum. Você pode usar dotnet-counters de modo multiplataforma por meio de um EventPipe. A ferramenta dotnet-counters é uma ferramenta global de CLI do dotnet multiplataforma que pode ser usada para monitorar os valores de contadores. Para saber como usar dotnet-counters para monitorar contadores, confira dotnet-counters ou siga o tutorial Medir o desempenho usando EventCounters.

dotnet-trace

A ferramenta dotnet-trace pode ser usada para consumir os dados de contadores por meio de um EventPipe. Veja um exemplo de uso de dotnet-trace para coletar dados de contadores.

dotnet-trace collect --process-id <pid> Sample.EventCounter.Minimal:0:0:EventCounterIntervalSec=1

Para obter mais informações de como coletar valores de contadores ao longo do tempo, confira a documentação do dotnet-trace.

Azure Application Insights

Os EventCounters podem ser consumidos pelo Azure Monitor, especificamente pelo Azure Application Insights. É possível adicionar e remover contadores, bem como especificar contadores personalizados ou já conhecidos. Para obter mais informações, confira Como personalizar contadores a serem coletados.

dotnet-monitor

A ferramenta dotnet-monitor facilita o acesso ao diagnóstico de um processo .NET de modo remoto e automatizado. Além dos rastreamentos, ela pode monitorar métricas, coletar despejos de memória e coletar despejos de GC. Ela é distribuída como uma ferramenta de CLI e uma imagem do Docker. Ela expõe uma API REST, e a coleta de artefatos de diagnóstico ocorre por meio de chamadas REST.

Para obter mais informações, confira dotnet-monitor.

Consumir no proc

Você pode consumir os valores do contador por meio da API EventListener. Uma EventListener é uma forma em processo de consumir qualquer evento escrito por instâncias de um EventSource no aplicativo. Para obter mais informações de como usar a API EventListener, confira EventListener.

Primeiro, o EventSource que produz o valor do contador precisa ser habilitado. Substitua o método EventListener.OnEventSourceCreated para receber uma notificação quando um EventSource for criado e, se esse for o EventSource correto com seus EventCounters, você poderá chamar EventListener.EnableEvents nele. Veja um exemplo de substituição:

protected override void OnEventSourceCreated(EventSource source)
{
    if (!source.Name.Equals("System.Runtime"))
    {
        return;
    }

    EnableEvents(source, EventLevel.Verbose, EventKeywords.All, new Dictionary<string, string>()
    {
        ["EventCounterIntervalSec"] = "1"
    });
}

Código de exemplo

Veja uma classe de exemplo EventListener que imprime todos os nomes e os valores de contadores do EventSource do runtime do .NET para publicar os contadores internos (System.Runtime) a cada segundo.

using System;
using System.Collections.Generic;
using System.Diagnostics.Tracing;

public class SimpleEventListener : EventListener
{
    public SimpleEventListener()
    {
    }

    protected override void OnEventSourceCreated(EventSource source)
    {
        if (!source.Name.Equals("System.Runtime"))
        {
            return;
        }

        EnableEvents(source, EventLevel.Verbose, EventKeywords.All, new Dictionary<string, string>()
        {
            ["EventCounterIntervalSec"] = "1"
        });
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        if (!eventData.EventName.Equals("EventCounters"))
        {
            return;
        }

        for (int i = 0; i < eventData.Payload.Count; ++ i)
        {
            if (eventData.Payload[i] is IDictionary<string, object> eventPayload)
            {
                var (counterName, counterValue) = GetRelevantMetric(eventPayload);
                Console.WriteLine($"{counterName} : {counterValue}");
            }
        }
    }

    private static (string counterName, string counterValue) GetRelevantMetric(
        IDictionary<string, object> eventPayload)
    {
        var counterName = "";
        var counterValue = "";

        if (eventPayload.TryGetValue("DisplayName", out object displayValue))
        {
            counterName = displayValue.ToString();
        }
        if (eventPayload.TryGetValue("Mean", out object value) ||
            eventPayload.TryGetValue("Increment", out value))
        {
            counterValue = value.ToString();
        }

        return (counterName, counterValue);
    }
}

Conforme mostrado acima, você precisa verificar se o argumento "EventCounterIntervalSec" está definido no argumento filterPayload ao chamar EnableEvents. Caso contrário, os contadores não poderão liberar valores, pois não sabem em qual intervalo ele deve ser liberado.

Confira também