Como leer programáticamente contadores de rendimiento en .Net Core

Tiempo de lectura: 8 minutos
Imagen ornamental para la entrada Como leer programáticamente contadores de rendimiento en .Net Core

Ha llegado el momento de cerrar el artículo sobre lo contadores de rendimiento. Hace un par de semanas planteábamos la situación sobre como poder crear contadores de rendimiento propios que podíamos leer desde la herramienta dotnet-counters.

Planteamos que es posible monitorizar con herramientas nativas de .Net Core los contadores internos, añadiendo así otra herramienta si queremos escribir código .Net Core de alto rendimiento.

Entre medias, mi compañero Ander se animó a escribir una entrada sobre cómo conseguir dejar fino el Sprint Backlog de una vez por todas. Entrada que te recomiendo sin dudas.

¿Por qué me interesa leer los contadores de rendimiento desde mi código .Net Core?

Anteriormente hablamos de lo útil que resulta poder obtener métricas del sistema. El hecho de poder controlar la CPU, o las recolecciones del GC, o incluso el número de peticiones que recibe un controlador ASP NET Core nos aporta una visión global de como esta nuestro software.

A nivel de control manual esto puede ser más que suficiente, pero si queremos automatizar ciertas acciones se queda corto. Ojo, la finalidad de esta entrada no es la de desbancar ni insinuar que herramientas de analítica de las trazas son peores que esto. Son herramientas con distinta finalidad que se complementan bien.

¿Dónde podría interesarme leer los contadores de rendimiento de .Net Core entonces? Pues en aquellos escenarios donde por ejemplo creemos una interfaz en un proceso diferente al de la lógica (una interfaz que consume un servicio por ejemplo). En mi caso concreto, yo utilicé estas técnicas para controlar el número de peticiones que realizaba a una API externa, por un lado, dejando constancia desde fuera del servicio de las peticiones realizadas, y por otro lado impidiendo que se realizasen más llamadas al pasar un cupo.

¿Qué maneras existen para leer los contadores de rendimiento en .Net Core?

Por suerte para nosotros, el equipo que desarrollo esta característica nos lo ha puesto fácil (más o menos). Todo depende que es lo que queremos leer y desde donde.

En el caso de que queremos leer nuestros propios contadores desde el mismo proceso que los escribe, bastaría con crear un EventListener y ponerlo a escuchar los eventos de nuestro EventSource.

La otra opción, es que queramos leer nuestros propios contadores desde otro proceso o que queramos leer contadores que no son nuestros. Si este es el caso, tenemos que hacer un desarrollo un poco más complejo utilizando un EventPipeEventSource.

Independientemente del método que elijamos para leer los contadores, vamos a recibir un diccionario clave-valor con cada una de las métricas asociadas a ese contador. La única diferencia que vamos a encontrarnos en el camino es si estamos leyendo contadores incrementales o no.

Por tanto, lo primero que vamos a hacer es crear un payload específico para procesar los datos de cada tipo de contador. Como además queremos trabajar de manera independiente del tipo para mostrarlo, vamos a crear una clase abstracta que tenga la funcionalidad común.

Por ejemplo, en esa clase vamos a almacenar el valor, el nombre del contador, y los datos de visualización de nombre, unidades y ratio de tiempo.

public abstract class CounterPayloadBase
{
    public string Name { get; protected set; }
    public double Value { get; protected set; }
    public string DisplayName { get; protected set; }
    public string DisplayUnits { get; protected set; }
    public string DisplayRateTimeScale { get; protected set; }

    public CounterPayloadBase()
    {
        Name = string.Empty;
        DisplayName = string.Empty;
        DisplayUnits = string.Empty;
        DisplayRateTimeScale = string.Empty;
    }
}

Sobre esta clase abstracta, vamos a implementar las dos clases especificas donde vamos a cambiar los datos del diccionario que van a cada sitio.

public class CounterPayload : CounterPayloadBase
{
    public CounterPayload(IDictionary<string, object> payloadFields)
    {
        Name = payloadFields["Name"].ToString();
        Value = Convert.ToDouble(payloadFields["Mean"]);
        DisplayName = payloadFields["DisplayName"].ToString();
        DisplayUnits = payloadFields["DisplayUnits"].ToString();

        // En caso de que las propiedades no esten asignadas, añadimos un valor por defecto
        DisplayName = DisplayName.Length == 0 ? Name : DisplayName;
    }
}

public class IncrementingCounterPayload : CounterPayloadBase
{
    public IncrementingCounterPayload(IDictionary<string, object> payloadFields, int interval)
    {
        Name = payloadFields["Name"].ToString();
        Value = (double)payloadFields["Increment"];
        DisplayName = payloadFields["DisplayName"].ToString();
        DisplayRateTimeScale = payloadFields["DisplayRateTimeScale"].ToString();
        DisplayUnits = payloadFields["DisplayUnits"].ToString();
        var timescaleInSec = DisplayRateTimeScale.Length == 0 ? 1 : (int)TimeSpan.Parse(DisplayRateTimeScale).TotalSeconds;
        Value *= timescaleInSec;

        // En caso de que las propiedades no esten asignadas, añadimos un valor por defecto
        DisplayName = DisplayName.Length == 0 ? Name : DisplayName;
        DisplayRateTimeScale = DisplayRateTimeScale.Length == 0 ? $"{interval} sec" : $"{timescaleInSec} sec";
    }
}

Por último, a fin de facilitarnos la vida, vamos a crear un método auxiliar que sea capaz de distinguir el tipo de contador que hemos leído y darnos el payload correcto. Esto lo vamos a poder saber porque en el payload existe un campo llamado ‘CounterType‘ cuyo valor es ‘Sum‘ si es incremental y ‘Mean‘ si no lo es.

public static class PayloadHelper
{
    public static CounterPayloadBase GetPayload(IDictionary<string, object> payloadFields, int _refreshInterval = 1)
    {
        if (payloadFields["CounterType"].Equals("Sum"))
        {
            return new IncrementingCounterPayload(payloadFields, _refreshInterval);
        }
        else
        {
            return new CounterPayload(payloadFields);
        }
    }
}

Leer tus propios contadores desde el mismo proceso

Vale, ahora que ya hemos creado la parte común que vamos a usar independiente del proceso, vamos a profundizar en los dos modelos. El primero es crear un EventListener para leer los contadores de un EventSource al que tenemos acceso.

Esto es algo muy simple ya que basta con crear una clase que herede de EventListener y que sobrescriba su método OnEventWritten() o asociar un manejador al evento EventWritten. Por cualquiera de estos dos caminos iremos obteniendo los datos de los contadores.

Ojo. Es importante que tengas en cuenta que la implementación base de OnEventWritten en EventListener lo que hace es invocar el evento EventWritten. Elije uno de los dos sistemas, pero no los dos o tendrás mensajes duplicados constantemente.

Para evitar que otros posibles eventos se nos cuelen, vamos a añadir un filtro sobre el propio nombre. Por ejemplo, la clase podría ser algo así:

public class ExampleEventListener : EventListener
{
    private readonly int _refreshInterval;

    public ExampleEventListener(int refreshInterval)
    {
        _refreshInterval = refreshInterval;
    }

    protected override void OnEventWritten(EventWrittenEventArgs eventData)
    {
        if (eventData.EventSource.Name != ExampleEventSource.SourceName)
        {
            return;
        }

        foreach (IDictionary<string, object> payloadFields in eventData.Payload)
        {
            var payload = PayloadHelper.GetPayload(payloadFields, _refreshInterval);
            Console.WriteLine($"{payload.Name}={payload.Value}-{payload.DisplayUnits}");
        }
        base.OnEventWritten(eventData);
    }
}

Con esto listo, basta con llamar al método EnableEvents indicándole el event source, el nivel de eventos, las keywords y los argumentos (en estos últimos le vamos a indicar el tiempo de refresco).

var refreshInterval = 5;
var exampleEventSource = new ExampleEventSource();
var exampleListener = new ExampleEventListener(refreshInterval);
var arguments = new Dictionary<string, string>
{
    {"EventCounterIntervalSec", $"{refreshInterval}"}
};
exampleListener.EnableEvents(exampleEventSource, EventLevel.Verbose, EventKeywords.All, arguments);

Y listo, con este código ya vamos a comenzar a leer nuestros contadores de rendimiento.

La imagen muestra los contadores de rendimiento de manera general. Nombre de contador=Valor de contador-Unidad

Leyendo cualquier contador

Gracias a los EventListener vamos a poder leer contadores sobre los que tengamos control y esto es muy interesante, pero no siempre vamos a poder llegar a todo. Métricas como la CPU o la RAM son parte de los contadores del propio runtime, por lo que no tenemos acceso directo a su EventSource.

Puede ser que por motivos de diseño o de arquitectura no tengamos acceso a la instancia del EventSource, ahí es donde entra en juego EventPipeEventSource. Precisamente dotnet-counters se basa en esto para poder ofrecernos los datos.

Para poder utilizar EventPipeEventSource, hay que trabajar un poco más, tirar de alguna paquetería NuGet, y confiar un poco en cosas arbitrarias.

¡Vamos con ello! Lo primero de todo es añadir los dos paquetes NuGet que vamos a nacesitar:

Ahora mismo hay una incidencia abierta donde se está resolviendo si EventPipeEventSource tiene que pasar a formar parte de NETCore.Client. Una vez que se resuelva podría no hacer falta añadir Tracing.TraceEvent.

Una vez hecho eso, lo primero que vamos a hacer es crear los argumentos y los proveedores. Para crear los argumentos al igual que hemos hecho hasta ahora, creamos un diccionario string-string.

var arguments = new Dictionary<string, string>
{
    {"EventCounterIntervalSec", $"{Interval}"}
};

En la parte de proveedores es donde vamos a aplicar un poco de magia negra y confiar en los valores arbitrarios del sistema. Con esto en cuenta vamos a crear una colección de EventPipeProvider.

Cada EventPipeProvider lo vamos a crear indicándole el nombre del grupo de contadores, el nivel, ‘0xffffffff’ (valor mágico) y los argumentos.

var providers = new List<EventPipeProvider>
{
    new EventPipeProvider("Example.Fixedbuffer", EventLevel.Informational, 0xffffffff, arguments),
    new EventPipeProvider("System.Runtime", EventLevel.Verbose, 0xffffffff, arguments)
};

Con esto ya tenemos listo lo más raro, lo siguiente es puro código. Vamos a crear un DiagnosticsClient ya que de este objeto obtendremos la sesión de los eventos. Para poder crear este objeto, tenemos que pasarle en su constructor el id del proceso cuyos contadores queremos leer.

var diagnosticsClient = new DiagnosticsClient(processId);

Desde el cliente vamos a crear la sesión que utilizaremos llamando a su método StartEventPipeSession indicándole los proveedores.

var session = diagnosticsClient.StartEventPipeSession(providers, false, 10);

¡Ahora sí que casi lo tenemos! Vamos a utilizar la sesión para crear el EventPipeEventSource que es el objeto final sobre el que vamos a trabajar.

var source = new EventPipeEventSource(session.EventStream);

Ya no queda nada. Sobre nuestro objeto EventPipeEventSource vamos a asociar un manejador al evento Dynamic.All:

source.Dynamic.All += obj =>
{
    if (obj.EventName.Equals("EventCounters"))
    {
        var payloadVal = (IDictionary<string, object>)(obj.PayloadValue(0));
        var payloadFields = (IDictionary<string, object>)(payloadVal["Payload"]);
        var payload = PayloadHelper.GetPayload(payloadFields, Interval);
        Console.WriteLine($"{payload.DisplayName}={payload.Value}-{payload.DisplayUnits}");
    }
};

Dentro de este manejador vamos a comprobar que el evento recibido es un «EventCounters» y vamos a recuperar primero el payload y después los campos del payload. Después para simplificar simplemente llamamos a las clases comunes que escribimos al principio para deserializar los datos del contador y ya lo tenemos listo para pintar en pantalla.

Ahora sí lo tenemos todo listo. Ya solo hace falta llamar al método Process del objeto EventPipeEventSource para empezar a leer los contadores.

source.Process();

Si en algún momento queremos dejar de leer los contadores, podemos llamar al método StopProcessing.

Después de todo esto, basta con arrancar nuestra aplicación para ver que todo funciona de la manera esperada.

Conclusión

Pese a que leer contadores de rendimiento por código es un escenario avanzado, conocer estas herramientas puede marcar la diferencia entre un desarrollo elegante y una chapuza.

En la medida de lo posible es mejor utilizar las herramientas disponibles y no reinventar la rueda en cada nueva necesidad. El uso de los contadores es especialmente recomendable (y debería ser obligatorio) cuando creamos librerías que consumen terceras partes. Dan información sobre el estado del sistema que puede ayudar a saber que todo funciona correctamente.

Como lo mejor siempre es tocar, probar, romper y arreglar, he dejado el código en Github para que puedas bajartelo.

Y tú, ¿conocías los contadores de rendimiento integrados en .Net Core?

Deja un comentario