Post

Journal du 16/01 - Se plonger dans l'observabilité - partie 2

(La première partie se trouve par ici)

Contexte

Suite à un échange avec un autre tech, où je mentionnais des erreurs de connexion à une DB de prod, sans qu’il y ait beaucoup de connexions utilisateurs en même temps, il me demande ce que j’utilise comme outil d’instrumentation !

Je n’ai rien de particulier en place, à pars Sentry pour suivre les logs d’erreurs des applications que je développe (en .NET).

Opérations

8. Intégration des metrics d’une Web App .NET 8

Grafana Cloud ne fonctionnait plus car j’étais connecté avec un VPN; et il semblerait que cela pose un soucis pour afficher certain des onglets de l’application.

J’ai donc essayé d’ajouter une nouvelle connection via l’écran “Ajouter une nouvelle connection” : Screen de la configuration d'une instance de OpenTelemetry Collector sur Grafana Cloud.

Or ce n’était absolument pas pertinent: en effet, c’est une aide et ça permet surtout de configurer un collector spécifique à une app. Dans mon cas, j’ai mis en place une instance de OpenTelemetry Collector qui va push ET pull des données, de ma WebApp et de ma ConsoleApp, pour ensuite les transmettre à Grafana Cloud. C’est donc une instance qu’on pourrait qualifier de “générique”.

D’ailleurs, en allant jeter un oeil sur l’onglet “Drilldowns->Metrics” de mon instance Grafana Cloud, j’ai découvert qu’il y avait bien des métrics qui remontaient depuis ma première configuration du 02/01 😁. Screen de l'écran Metrics d'une instance de de Grafana Cloud montrant des données d'une application web .NET.

Qu’est ce que j’ai configuré dans le code de ma web app ?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService(
        serviceName: "clientwebapp",
        serviceVersion: Assembly.GetEntryAssembly()?
                        .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
                        .InformationalVersion
                        ?? "unknown"))
    .WithMetrics(metrics =>
    {
        metrics
        // ASP.NET Core request metrics (duration, active requests, etc.)
        .AddAspNetCoreInstrumentation()
        // outgoing HTTP calls (HttpClient)
        .AddHttpClientInstrumentation()
        // runtime metrics (GC, CPU time, threadpool, etc.)
        .AddRuntimeInstrumentation()
        // exposes a Prometheus scrape endpoint
        .AddPrometheusExporter();
    });

// other non-related stuff

// IApplicationBuilder app 
app.UseRequestLocalization(app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>().Value);

J’ai aussi eu besoin des dépendances suivantes :

1
2
3
4
5
    <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" />
    <PackageReference Include="OpenTelemetry.Extensions.Hosting" />
    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
    <PackageReference Include="OpenTelemetry.Instrumentation.Http" />
    <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />

Sur mon

9. Intégration des metrics d’une Console App .NET 8

Commençons par configurer l’application, pour qu’elle puisse bien envoyer des metrics :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r =>
        r.AddService(
            serviceName: "jobs",
            serviceVersion: Assembly.GetEntryAssembly()?
                            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
                            .InformationalVersion
                            ?? "unknown"))
    .WithMetrics(m =>
    {
        // outgoing HTTP calls (HttpClient)
        m.AddHttpClientInstrumentation();

        // OTLP exporter → Collector
        m.AddOtlpExporter();
    });

Je configure uniquement “en dur”, le nom du service, ainsi que le numéro de version. Ensuite, pour configurer le service OTLP à contacter, je passe par des variables d’env, directement sur mon node, comme ceci :

1
OTEL_EXPORTER_OTLP_ENDPOINT=http://mon_ip_interne:4317

J’ai aussi effectué un scan pour vérifier que je peux bien contacter cette IP et le port en question, depuis mon application (ils sont hébergés sur 2 nodes différents) :

1
nc -vz mon_ip_interne 4317

10. Intégration des traces sur les deux applications .NET

Sur l’application web, ajout d’une librairie pour gérer les traces, avec la configuration suivante :

1
2
    <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
    <PackageReference Include="OpenTelemetry.Instrumentation.Hangfire" />
1
2
3
4
5
6
7
8
9
10
    services.AddOpenTelemetry()
        //...
        .WithTracing(tracing =>
        {
            tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation(o => { o.RecordException = true; })
            .AddHangfireInstrumentation()
            .AddOtlpExporter();
        })

J’en ai aussi profité pour récupérer des informations sur Hangfire.

Idem, pour l’application console, , étant donné que je l’utilise :

1
2
    <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
    <PackageReference Include="OpenTelemetry.Instrumentation.Hangfire" />
1
2
3
4
5
6
7
8
9
builder.Services.AddOpenTelemetry()
    //...
    .WithTracing(tracing =>
    {
        tracing
        .AddHttpClientInstrumentation(o => { o.RecordException = true; })
        .AddHangfireInstrumentation()
        .AddOtlpExporter();
    })

11. Intégrations des metrics et traces sur Nginx

J’ai un serveur Nginx qui est en proxy de ma web app.

Je voudrais aussi pouvoir récupérer des metrics et des traces, afin d’introduire une corrélation entre la requête qui entre et les données qui ressortent de ma web app.

J’utilise l’application Nginx, qui est proposé directement comme type d’environnement sur JElastic, et donc, je n’ai pas les droits d’admin, pour pouvoir faire tout ce que je veux dessus ! Ainsi, je ne peux malheureusement pas ajouter dans nginx.conf :

1
load_module modules/ngx_http_opentelemetry_module.so;

Ce module permet, nativement, de récupérer des metrics spécifiques.

J’ai comme sujet à moyen terme, de ne plus utiliser directement cette application, et passer directement par un conteneur docker (qui héritera de celui-ci, ou non).

En attendant, pas d’autres choix, que de passer par les logs de Nginx.

Ainsi, une fois ajouté dans nginx.conf, la config suivante :

1
2
3
proxy_set_header traceparent $http_traceparent;
proxy_set_header tracestate  $http_tracestate;
proxy_set_header baggage     $http_baggage;

Il faut aussi modifier le contenu des logs, afin d’ajouter les valeurs configurés précédements. Dans mon cas, cf ci-dessous les éléments qui nous intéressent :

1
2
3
log_format  main  #other stuff
                    'traceparent="$http_traceparent" tracestate="$ht  tp_tracestate" '
                    'upstream_trace_id="$upstream_http_x_trace_id"';

upstream_trace_id va permettre à Nginx, de récupérer l’id créé par l’application .NET, et de permettre d’identifier que c’est une seule et même trace. J’ai développé un petit middleware qui set ce trace id, que j’ai ajouté juste après l’exposition du Endpoint des metrics :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
internal sealed class AddTraceIdToResponseHeaders
{
    private const string _traceHeaderName = "X-Trace-Id";
    private readonly RequestDelegate _next;

    public AddTraceIdToResponseHeaders(RequestDelegate next) => _next = next;

    public Task InvokeAsync(HttpContext context)
    {
        var traceId = Activity.Current?.TraceId.ToString();
        context.Response.OnStarting(() =>
        {
            if (!string.IsNullOrEmpty(traceId) && !context.Response.Headers.ContainsKey(_traceHeaderName))
            {
                context.Response.Headers[_traceHeaderName] = traceId;
            }
            return Task.CompletedTask;
        });

        return _next(context);
    }
}

internal static class AddTraceIdToResponseHeadersExtensions
{
    public static IApplicationBuilder UseAddTraceIdToResponseHeaders(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<AddTraceIdToResponseHeaders>();
    }
}

En effet, par défaut, le client pourrait ne pas fournir de traceparent, et sans le module ngx_http_opentelemetry_module.so, aucun id n’est générée.

J’ai placé l’appel à UseAddTraceIdToResponseHeaders juste après celui de UseRouting().

Du côté du collecteur, j’ai perdu un peu de temps car, je me suis rendu compte que je n’avais pas monté mon fichier de log de Nginx correctement !

En effet, je run une conteneur du Collecteur sur un node qui est de type Docker Engine. J’ai bien monté le répertoire où se trouve les logs sur mon node.

Mais j’avais complètement oublié qu’il fallait aussi que je le monte sur mon conteneur ! Car mon node, ce n’est pas directement une instance du Collecteur.

Ainsi, j’ai créé un docker-compose, en y ajoutant un mount du volume qui est partagé sur le node.

Et ça fonctionne !

Je peux donc suivre une requête HTTP entrée sur mon proxy puis sur ma web app !

Ma configuration final de mon conteneur OpenTelemetry Collector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
extensions:
  basicauth/grafana_cloud:
    client_auth:    
      username: ".."    
      password: "..."

receivers:
otlp:
    protocols:
    grpc:
        endpoint: 0.0.0.0:4317
    http:
        endpoint: 0.0.0.0:4318
filelog/nginx:
  include:
    - /data/external-logs/proxy/localhost.access.log
  start_at: end
  operators:
    # Parse traceparent + upstream_trace_id out of the line (simple + robust)
    - type: regex_parser
      regex: 'traceparent="(?P<traceparent>[^"]*)"'
    - type: regex_parser
      regex: 'upstream_trace_id="(?P<upstream_trace_id>[^"]*)"'
    # If upstream_trace_id exists, use it as trace_id
    - type: move
      if: 'attributes.upstream_trace_id != ""'
      from: attributes.upstream_trace_id
      to: attributes.trace_id
    # Else extract trace_id from traceparent (00-<traceid>-<spanid>-..)
    - type: regex_parser
      if: 'attributes.trace_id == "" and attributes.traceparent != ""'
      regex: '^00-(?P<trace_id>[a-f0-9]{32})-'
      parse_from: attributes.traceparent
    # Clean up
    - type: remove
      field: attributes.traceparent

prometheus:
    config:
    scrape_configs:
        - job_name: "clientwebapp"
        metrics_path: /metrics
        scrape_interval: 20s
        static_configs:
            - targets: ["yourlocalipOrDNSAlias:80"]  # ip of the node of the clientwebapp instance

 processors:
   # Best practice: first in pipeline to apply backpressure early
   memory_limiter:
     check_interval: 5s
     limit_mib: 512
     spike_limit_mib: 128
   resourcedetection:
     detectors: ["env", "system"]
     override: false
   batch:
   resource/add_environment:
     attributes:
       - key: deployment.environment
         value: prod
         action: upsert

exporters:
debug:
    verbosity: detailed
otlphttp/grafana_cloud:
    #https://github.com/open-telemetry/opentelemetry-collector/tree/main/exporter/otlpexporter
    endpoint: "https://otlp-gateway-prod-eu-central-0.grafana.net/otlp"
    auth:
    authenticator: basicauth/grafana_cloud

service:
  extensions: [basicauth/grafana_cloud]
  pipelines:
    metrics:
      receivers: [otlp, prometheus]
      processors: [resource/add_environment, memory_limiter, resourcedetection, batch]
      exporters: [otlphttp/grafana_cloud]
    traces:
      receivers: [otlp]
      processors: [resource/add_environment, memory_limiter, batch]
      exporters: [otlphttp/grafana_cloud]
    logs/nginx:
      receivers: [filelog/nginx]
      processors: [resource/add_environment, batch]
      exporters: [otlphttp/grafana_cloud]
This post is licensed under CC BY 4.0 by the author.