Back to "Vers 1.0 : préparer la production 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

Vers 1.0 : préparer la production Umami.NET

Thursday, 20 November 2025

Présentation

Quand j'ai été intégré pour la première fois Analyse d'Umami Dans ma plateforme de blog, j'ai rapidement rencontré une réalité frustrante : la documentation API d'Umami est... Soyons charitables et appelons ça "minimaux". Les messages d'erreur sont au mieux cryptiques, inexistants au pire. Les paramètres changent d'une version à l'autre sans avertissement.

Alors j'ai construit Umami.NET - pas seulement comme un simple wrapper HTTP, mais comme une bibliothèque client prête à la production qui compense tous les quirks d'Umami. Comme j'ai travaillé vers une version 1.0, je me suis concentré sur trois domaines critiques:

  1. Validation complète avec messages d'erreur utiles
  2. Infrastructure d'essai robuste
  3. Gestion de l'erreur gracieuse pour les scénarios du monde réel

Laissez-moi vous expliquer ce qui prépare la production de cette bibliothèque.

NuGet Licence: MIT .NET

Le problème : l'écart de documentation d'Umami

Voici ce contre quoi vous vous opposez lorsque vous travaillez directement avec l'API d'Ummi :

  • Aucune validation d'entrée - Envoyer un GUID mal formé ?
  • Réponses cryoptiques - Bot détecté ? "beep boop"C'est ça.
  • Briser les changements - Paramètres rebaptisés entre les versions (path vs url, hostname vs host)
  • La confusion dans l'horodatage Bonne chance pour le trouver.
  • Réponses du JWT - Parfois pleine charge utile, parfois juste un identifiant de visiteur. Aucune documentation expliquant quand ou pourquoi.

C'est bien pour un prototype rapide, mais pour la production? Vous avez besoin de quelque chose de mieux.

Clauses de garde: Échec rapide avec le contexte

Les pires bogues sont ceux qui échouent silencieusement. Umami.NET capture les erreurs de configuration au démarrage avant qu'elles ne puissent causer des problèmes de production.

Validation de la configuration

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

Cela fonctionne au démarrage dans votre Program.cs. Si votre configuration est incorrecte, vous savez immédiatement - pas quand le premier événement analytique essaie d'envoyer.

Demander une validation avec des suggestions utiles

Mais la vraie magie est dans l'aide de la chaîne de requête. Découvrez ces messages d'erreur:

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);
            }
        }
    }
}

Remarquez que Suggestion: préfixe? Chaque message d'erreur vous indique ce qui s'est mal passé et comment le réparer. C'est la documentation que Umami aurait dû fournir.

Validation de la plage de dates

Lorsque vous créez des requêtes analytiques, les plages de dates peuvent être délicates. La bibliothèque capture ces erreurs pour vous :

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).");
    }
}

Manipulation des Quirks d'Umami

Le problème du "Beep Boop"

La détection de bot d'Umami renvoie une réponse en texte simple : "beep boop"Ce n'est pas un bon code d'état.

Voici comment Umami.NET s'en occupe :

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);
    }
}

Votre code obtient un enum propre:

public enum ResponseStatus
{
    Failed,
    BotDetected,
    Success
}

Plus d'analyse des réponses bizarres - il suffit de vérifier l'état.

Changements de nom du paramètre

Umami a rebaptisé les paramètres entre les versions de l'API. Est-ce qu'ils ont documenté cela ? Bien sûr que non. La bibliothèque gère les deux :

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

Conversions d'horodatage

Umami utilise les millisecondes Unix pour les horodatages. Voici un assistant qui le rend indolore:

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

Maintenant vous pouvez travailler avec normal DateTime et laissez la bibliothèque gérer la conversion.

Traitement de l'arrière-plan avec les canaux

L'analytique ne devrait jamais bloquer votre application. Umami.NET inclut un expéditeur de fond en utilisant 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");
            }
        }
    }
}

Les événements sont en file d'attente en mémoire et traités asynchronement. Vos demandes Web reviennent instantanément, l'analyse se produit en arrière-plan.

Réessayez les politiques avec Polly

Les défaillances du réseau se produisent. La bibliothèque utilise Polly pour les appels HTTP résilients :

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);
}

Les défaillances transitoires et les erreurs 503 déclenchent des retraits automatiques avec un recul exponentiel. Vos analyses sont résilientes aux problèmes de réseau temporaires.

Authentification avec Auto-Retry

Lors de la récupération des données d'analyse (et pas seulement de l'envoi d'événements), vous avez besoin d'authentification. La bibliothèque gère automatiquement l'expiration des jetons :

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);
}

Vous n'avez jamais à penser à la gestion de jetons - ça marche juste.

Infrastructure d'essai

Le code prêt à la production nécessite des tests complets. Voici ce que j'ai construit:

FakeLogger pour la vérification du journal

Utilisation de Microsoft FakeLogger paquet, les tests peuvent vérifier le comportement de l'enregistrement:

[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));
}

Handlers HTTP Mock personnalisés

Tester les opérations d'async est difficile. Voici un modèle utilisant 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
}

Ce modèle garantit :

  • Les événements de fond sont effectivement traités
  • Le traitement se termine dans un délai raisonnable
  • Les affirmations dans le gestionnaire de simulation sont correctement rapportées

Couverture d'essai complète

La suite d'essai couvre:

  • Validation de la configuration (IDG invalides, URL manquantes)
  • Suivi des événements avec et sans données
  • Suivi de l'affichage de la page
  • Identification de l'utilisateur
  • Manipulation de la détection du bot
  • Décodage de la réponse JWT
  • Traitement de l'arrière-plan avec timeouts
  • Validation de la plage de dates
  • Génération de chaînes de requêtes
  • Authentification et jeton rafraîchissement
  • Recherche de données sur les paramètres et les pages d'affichage

Utilisation réelle dans le monde

Voici comment il est simple d'utiliser dans une application ASP.NET Core:

Mise en place dans Program.cs

builder.Services.SetupUmamiClient(builder.Configuration);

C'est ça, la bibliothèque lit votre appsettings.json:

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

Track Events

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

Saisir les données analytiques

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

Quelle est la suite pour 1.0 ?

La bibliothèque est complète et testée en production sur ce blog. Avant la sortie 1.0, je me concentre sur:

  • Documentation complète de l'API
  • Édition de paquets NuGet
  • Critères de performance
  • D'autres méthodes de commodité pour les requêtes analytiques communes

Le présent règlement entre en vigueur le vingtième jour suivant celui de sa publication au Journal officiel de l'Union européenne.

Construire une bibliothèque prête à la production, ce n'est pas seulement emballer une API - c'est créer une expérience qui est mieux que d'utiliser l'API directement. Umami.NET compense les lacunes de documentation d'Umami avec:

  • Validation qui explique ce qui s'est mal passé et comment le réparer
  • Gestion gracieuse des comportements excentriques de l'API
  • Test complet qui prouve qu'il fonctionne
  • Traitement d'arrière-plan qui ne bloque pas votre application
  • Gestion d'erreur résiliente avec rétries automatiques

Si vous utilisez Umami analytics dans une application .NET, j'aimerais que vous tentiez Umami.NET. Il est open source, fortement testé, et conçu pour faciliter votre vie.

Vous avez des questions ou des suggestions? Ouvrez un numéro sur GitHub ou contactez-nous dans les commentaires ci-dessous!

logo

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