Back to "Hacia 1.0: Preparar la producción de Umami.NET"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

Analytics C# Open Source Umami

Hacia 1.0: Preparar la producción de Umami.NET

Thursday, 20 November 2025

Introducción

Cuando me integré por primera vez Análisis de Umami En mi plataforma de blog, rápidamente me encontré con una realidad frustrante: la documentación de la API de Umami es... seamos caritativos y lo llamemos "mínimo". Los mensajes de error son crípticos en el mejor de los casos, inexistentes en el peor. Los parámetros cambian entre versiones sin previo aviso. Y ni siquiera me consigan empezar en la respuesta de detección de bots "beep boop".

Así que construí Umami.NET - no solo como un simple envoltorio HTTP, sino como una biblioteca cliente lista para la producción que compensa todas las peculiaridades de Umami.

  1. Validación completa con mensajes de error útiles
  2. Infraestructura de ensayo robusta
  3. Manejo de errores agraciados para escenarios del mundo real

Déjame guiarte a través de lo que hace que esta biblioteca esté lista para la producción.

NuGet Licencia: MIT .NET

El problema: la brecha de documentación de Umami

Esto es a lo que te enfrentas cuando trabajas directamente con la API de Umami:

  • Sin validación de entrada - ¿Enviar una guía gráfica mal formada?
  • Respuestas crípticas - ¿Bot detectado? "beep boop"Eso es todo.
  • Rompiendo cambios - Parámetros renombrados entre versiones (path vs. url, hostname vs. host)
  • Confusión de marca de tiempo - ¿Unix milisegundos?
  • Respuestas JWT - A veces cargas útiles completas, a veces sólo una identificación de visitante. No hay documentación que explique cuándo o por qué.

Esto está bien para un prototipo rápido, pero para la producción? Necesitas algo mejor.

Cláusulas de protección: Fallar rápidamente con contexto

Los peores errores son los que fallan silenciosamente. Umami.NET detecta errores de configuración al iniciar antes de que puedan causar problemas en la producción.

Validación de la configuración

public static void ValidateSettings(UmamiClientSettings settings)
{
    // Guard: UmamiPath is required
    if (string.IsNullOrEmpty(settings.UmamiPath))
        throw new ArgumentNullException(settings.UmamiPath,
            "UmamiUrl is required");

    // Guard: UmamiPath must be valid URI
    if (!Uri.TryCreate(settings.UmamiPath, UriKind.Absolute, out _))
        throw new FormatException(
            "UmamiUrl must be a valid Uri");

    // Guard: WebsiteId is required
    if (string.IsNullOrEmpty(settings.WebsiteId))
        throw new ArgumentNullException(settings.WebsiteId,
            "WebsiteId is required");

    // Guard: WebsiteId must be valid GUID
    if (!Guid.TryParseExact(settings.WebsiteId, "D", out _))
        throw new FormatException(
            "WebSiteId must be a valid Guid");
}

Esto se ejecuta al iniciar en su Program.cs. Si su configuración es incorrecta, usted sabe inmediatamente - no cuando el primer evento de análisis intenta enviar.

Solicitud de validación con sugerencias útiles

Pero la magia real está en el ayudante de la cadena de consulta. Echa un vistazo a estos mensajes de error:

public static string ToQueryString(this object obj)
{
    if (obj == null)
    {
        throw new ArgumentNullException(nameof(obj),
            "Cannot convert null object to query string. " +
            "Suggestion: Ensure you create and populate a request object " +
            "before calling ToQueryString().");
    }

    foreach (var property in objectType.GetProperties())
    {
        if (attribute.IsRequired)
        {
            if (propertyValue == null)
            {
                throw new ArgumentException(
                    $"Required parameter '{propertyName}' " +
                    $"(property '{property.Name}') cannot be null. " +
                    $"Suggestion: Set the {property.Name} property " +
                    $"on your {objectType.Name} object...",
                    property.Name);
            }

            // For strings, check for empty/whitespace
            if (propertyValue is string strValue &&
                string.IsNullOrWhiteSpace(strValue))
            {
                throw new ArgumentException(
                    $"Required parameter '{propertyName}' " +
                    $"cannot be empty or whitespace. " +
                    $"Suggestion: Set {property.Name} to a valid non-empty value.",
                    property.Name);
            }
        }
    }
}

Nótese que Suggestion: prefijo? Cada mensaje de error le dice lo que salió mal y cómo arreglarlo. Esta es la documentación que Umami debería haber proporcionado.

Validación del intervalo de fechas

Cuando se construyen consultas analíticas, los rangos de fechas pueden ser complicados. La biblioteca detecta estos errores para usted:

public DateTime StartAtDate
{
    get => _startAtDate;
    set
    {
        if (_endAtDate != default && value > _endAtDate)
        {
            throw new ArgumentException(
                $"StartAtDate ({value:O}) must be before EndAtDate ({_endAtDate:O}). " +
                "Suggestion: Set StartAtDate to an earlier date or adjust EndAtDate.",
                nameof(StartAtDate));
        }
        _startAtDate = value;
    }
}

public virtual void Validate()
{
    if (StartAtDate == default)
    {
        throw new InvalidOperationException(
            "StartAtDate is required. " +
            "Suggestion: Set StartAtDate to a valid date " +
            "(e.g., DateTime.UtcNow.AddDays(-7) for last 7 days).");
    }
}

Manejando las Quirks de Umami

El problema del "beep boop"

La detección de bots de Umami devuelve una respuesta de texto plano: "beep boop"JSON no, no es un código de estado adecuado.

Así es como Umami.NET lo maneja:

public async Task<UmamiDataResponse> DecodeResponse(HttpResponseMessage response)
{
    var responseString = await response.Content.ReadAsStringAsync();

    // Handle bot detection
    if (responseString.Contains("beep") && responseString.Contains("boop"))
    {
        logger.LogWarning("Bot detected - data not stored in Umami");
        return new UmamiDataResponse(ResponseStatus.BotDetected);
    }

    // Handle JWT response
    try
    {
        var jwtPayload = DecodeJwt(responseString);
        return new UmamiDataResponse(ResponseStatus.Success, jwtPayload);
    }
    catch (Exception e)
    {
        logger.LogError(e, "Failed to decode response");
        return new UmamiDataResponse(ResponseStatus.Failed);
    }
}

Su código obtiene un enum limpio:

public enum ResponseStatus
{
    Failed,
    BotDetected,
    Success
}

No más análisis de respuestas extrañas - sólo comprobar el estado.

Cambios en el nombre del parámetro

Umami cambió el nombre de los parámetros entre las versiones de API. ¿Lo documentaron? Por supuesto que no. La biblioteca maneja ambos:

// Support both old and new parameter names
request.Path = queryParams["path"] ?? queryParams["url"];
request.Hostname = queryParams["hostname"] ?? queryParams["host"];

Conversiones de marca de tiempo

Umami utiliza unix milisegundos para las marcas de tiempo. Aquí hay un ayudante que lo hace indoloro:

public static long ToMilliseconds(this DateTime dateTime)
{
    var dateTimeOffset = new DateTimeOffset(dateTime.ToUniversalTime());
    return dateTimeOffset.ToUnixTimeMilliseconds();
}

Ahora puedes trabajar con lo normal. DateTime objetos y dejar que la biblioteca maneje la conversión.

Procesamiento de antecedentes con canales

El análisis nunca debe bloquear su aplicación. Umami.NET incluye un remitente de fondo usando System.Threading.Channels:

public class UmamiBackgroundSender : IHostedService
{
    private readonly Channel<UmamiPayload> _channel;
    private readonly UmamiClient _client;

    public async Task Track(string eventName,
        string? url = null,
        UmamiEventData? data = null)
    {
        var payload = new UmamiPayload
        {
            Website = _settings.WebsiteId,
            Name = eventName,
            Url = url ?? string.Empty,
            Data = data
        };

        // Non-blocking write to channel
        await _channel.Writer.WriteAsync(payload);
    }

    private async Task ProcessQueue(CancellationToken stoppingToken)
    {
        await foreach (var payload in _channel.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                await _client.Send(payload);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to send event to Umami");
            }
        }
    }
}

Los eventos se hacen cola en la memoria y se procesan asíncronamente. Sus solicitudes web regresan instantáneamente, el análisis ocurre en segundo plano.

Reintentar políticas con Polly

Los fallos de red ocurren. La biblioteca utiliza Polly para llamadas HTTP resilientes:

public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    var delay = Backoff.DecorrelatedJitterBackoffV2(
        TimeSpan.FromSeconds(1),
        retryCount: 3);

    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .OrResult(msg => msg.StatusCode == HttpStatusCode.ServiceUnavailable)
        .WaitAndRetryAsync(delay);
}

Fallos transitorios y errores 503 desencadenan retrocesos automáticos con retroceso exponencial. Sus análisis son resistentes a problemas temporales de red.

Autenticación con reintento automático

Al obtener datos analíticos (no solo enviar eventos), necesita autenticación. La biblioteca maneja la expiración de tokens automáticamente:

public async Task<UmamiResult<StatsResponseModel>> GetStats(StatsRequest statsRequest)
{
    var response = await _httpClient.GetAsync(url);

    // Token expired? Re-authenticate and retry
    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        await _authService.Login();
        return await GetStats(statsRequest); // Recursive retry
    }

    // Parse and return
    var content = await response.Content.ReadFromJsonAsync<StatsResponseModel>();
    return new UmamiResult<StatsResponseModel>(
        response.StatusCode,
        response.ReasonPhrase ?? string.Empty,
        content);
}

Nunca tienes que pensar en la gestión de fichas - sólo funciona.

Infraestructura de ensayo

El código preparado para la producción necesita pruebas completas.

FakeLogger para la verificación de registros

Uso de Microsoft's FakeLogger paquete, las pruebas pueden verificar el comportamiento de registro:

[Fact]
public async Task Login_Success_LogsMessage()
{
    // Arrange
    var fakeLogger = new FakeLogger<AuthService>();
    var authService = new AuthService(httpClient, settings, fakeLogger);

    // Act
    await authService.Login();

    // Assert
    var logs = fakeLogger.Collector.GetSnapshot();
    Assert.Contains("Login successful", logs.Select(x => x.Message));
}

Manipuladores HTTP de mock personalizados

Probar las operaciones de async es complicado. Aquí hay un patrón usando TaskCompletionSource:

[Fact]
public async Task BackgroundSender_ProcessesEventAsynchronously()
{
    var tcs = new TaskCompletionSource<bool>();

    var handler = EchoMockHandler.Create(async (message, token) =>
    {
        try
        {
            // Assert the request was sent correctly
            var payload = await message.Content.ReadFromJsonAsync<UmamiPayload>();
            Assert.Equal("test-event", payload.Name);

            tcs.SetResult(true); // Signal test completion
            return new HttpResponseMessage(HttpStatusCode.OK);
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return new HttpResponseMessage(HttpStatusCode.InternalServerError);
        }
    });

    // Track event
    await backgroundSender.Track("test-event");

    // Wait for background processing with timeout
    var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(1000));
    if (completedTask != tcs.Task)
        throw new TimeoutException("Event was not processed within timeout");

    await tcs.Task; // Throw if assertions failed
}

Este patrón asegura:

  • Los acontecimientos de fondo se procesan realmente
  • El procesamiento se completa en un plazo razonable
  • Las declaraciones en el manipulador simulado se informan correctamente

Cobertura completa de la prueba

La suite de pruebas cubre:

  • • Validación de la configuración (GUIDs inválidos, URLs faltantes)
  • • Seguimiento de eventos con y sin datos
  • • Seguimiento de la vista de página
  • • Identificación del usuario
  • • Manejo de la detección de bots
  • • Descifrado de la respuesta de JWT
  • • Procesamiento de fondo con tiempos de espera
  • • Validación del intervalo de fechas
  • • Generación de cadenas de preguntas
  • • Autenticación y actualización de tokens
  • • Recuperación de datos de métricas y vistas de página

Uso en el mundo real

He aquí lo sencillo que es usar en una aplicación ASP.NET Core:

Configuración en Program.cs

builder.Services.SetupUmamiClient(builder.Configuration);

Ya está, la biblioteca te lee. appsettings.json:

{
  "Analytics": {
    "UmamiPath": "https://analytics.yoursite.com",
    "WebsiteId": "your-website-guid"
  }
}

Acontecimientos de pista

public class HomeController : Controller
{
    private readonly UmamiBackgroundSender _umami;

    public HomeController(UmamiBackgroundSender umami)
    {
        _umami = umami;
    }

    public IActionResult Index()
    {
        // Non-blocking event tracking
        await _umami.TrackPageView("/", "Home Page");

        return View();
    }

    [HttpPost]
    public async Task<IActionResult> Subscribe(string email)
    {
        // Track with custom data
        await _umami.Track("newsletter-signup",
            data: new UmamiEventData
            {
                { "source", "homepage" },
                { "email_domain", email.Split('@')[1] }
            });

        return RedirectToAction("ThankYou");
    }
}

Obtener datos analíticos

public class AnalyticsDashboardController : Controller
{
    private readonly IUmamiDataService _umamiData;

    public async Task<IActionResult> Stats()
    {
        var request = new StatsRequest
        {
            StartAtDate = DateTime.UtcNow.AddDays(-30),
            EndAtDate = DateTime.UtcNow
        };

        var result = await _umamiData.GetStats(request);

        if (result.Status == HttpStatusCode.OK)
        {
            var stats = result.Data;
            // stats.Visitors, stats.PageViews, stats.BounceRate, etc.
            return View(stats);
        }

        return View("Error");
    }
}

¿Qué sigue para 1.0?

La biblioteca está completa y probada en producción en este mismo blog. Antes de la versión 1.0, me estoy centrando en:

  • Documentación completa de API
  • NuGet publicación de paquetes
  • Parámetros de rendimiento
  • Métodos adicionales de conveniencia para consultas analíticas comunes

Conclusión

Construir una biblioteca lista para la producción no se trata sólo de envolver una API - se trata de crear una experiencia que es mejor Umami.NET compensa los vacíos de documentación de Umami con:

  • Validación que explica lo que salió mal y cómo arreglarlo
  • Manejo agraciado de comportamientos peculiares de API
  • Pruebas completas que prueban que funciona
  • Procesamiento de fondo que no bloquea tu aplicación
  • Manejo de errores resiliente con reintentos automáticos

Si estás usando el análisis de Umami en una aplicación .NET, me encantaría que lo intentaras. Umami.NETEs de código abierto, muy probado, y diseñado para hacer su vida más fácil.

¿Tienes preguntas o sugerencias? ¡Abre un número en GitHub o contacta en los comentarios de abajo!

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.