OpenTelemetry : Tracer et instrumentaliser votre code applicatif
Originellement, deux projets open-source existaient pour permettre de mettre en place du tracing dans vos applications : OpenCensus et OpenTracing.
Ces deux projets avaient un même but et ont donc décidés de fusionner pour former OpenTelemetry. Ils sont désormais incubés au sein du CNCF (Cloud Native Computing Foundation).
L'objectif reste le même : permettre aux développeurs de mettre en place du tracing distribué sur leurs applications avoir d'une vision de bout-en-bout sur ce qu'il se passe de la requête utilisateur sur un front web aux différents services back-end qui sont appelés.
Cela permet de faciliter grandement l'identification de problèmes en production ainsi que le debug pour connaitre le contexte précis d'une requête.
Si vous n'avez pas mis en place dès le début sur vos projets une stack, il faudra re-passer un peu partout sur votre code pour le mettre en place mais vous pouvez l'ajouter progressivement : commencer par mettre en place les traces dans les parties critiques de votre code puis venir compléter par la suite avec des logs et instrumentalisations sur les divers outils que vous utilisez (bases de données, APIs HTTP/gRPC, cache, ...).
Comment fonctionne OpenTelemetry ?
Globalement, vous devez installer un collector
et vos applications back-end auront la tâche d'envoyer les traces à ce collecteur. Il se chargera ensuite d'exporter
les traces et métriques à un service externe tels que Jaeger, Prometheus, Datadog ou autre.
L'agent OpenTelemetry peut être exécuté en tant que simple binaire ou vous pouvez également l'installer sur votre cluster Kubernetes, en sidecar ou via un DaemonSet.
Pour plus d'informations sur l'installation, référez-vous à cette page.
Ajouter des traces à mon code
Ce qui va nous intéresser maintenant est la mise en place des premières traces dans vos applications.
J'utiliserais la librairie Go dans ces exemples mais référez-vous à la documentation pour découvrir comment utiliser la librairie du langage de votre choix (PHP, Python, Ruby, Rust, Java, Javascript, ...).
Dans cet exemple, notre code va ouvrir une connexion gRPC via le package otlptracegrpc pour envoyer nos traces au collecteur OpenTelemetry. Il nous faut donc instancier un exporter :
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/backoff"
)
traceExporter, err := otlptracegrpc.New(
ctx,
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithEndpoint("collector-endpoint.svc.local:50052"),
otlptracegrpc.WithDialOption(
grpc.WithConnectParams(grpc.ConnectParams{
Backoff: backoff.Config{
BaseDelay: 1 * time.Second,
Multiplier: 1.6,
MaxDelay: 15 * time.Second,
},
MinConnectTimeout: 0,
}),
),
)
if err != nil {
panic(err)
}
En cas de problème de connexion, comme spécifié dans cet exemple, on peut également mettre en place un système de retry en mode backoff pour re-tenter une connexion de façon exponentielle.
Il nous faut ensuite instancier un TracerProvider
qui nous permettra de créer des traces dans notre application :
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
"go.opentelemetry.io/otel/trace"
)
// ...
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("my-application"),
semconv.ServiceVersionKey.String("v1.0.0"),
)),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(traceExporter)),
)
otel.SetTracerProvider(tracerProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
À l'initialisation de ce tracerProvider
, on remarque que l'on spécifie des attributs par défaut : l'URL du schéma OpenTelemetry utilisé, un nom d'application (appelé service) ainsi qu'un numéro de version. Ces informations seront alors disponibles sur votre interface finale et vous aurez la possibilité de requêter dessus.
Ces attributs par défaut seront aussi définis sur chaque trace qui sera créé dans votre application.
On crée également un BatchSpanProcessor
en spécifiant le traceExporter
créé précédemment. Ainsi, à chaque nouvelle trace, le TracerProvider
enverra (en mode batch) les données télémetriques à notre exporter.
Une fois le tracerProvider
prêt à être utilisé, il ne nous reste plus qu'à créer une première trace dans notre application.
Une petite notion avant de continuer :
- Un
span
représente une portion de code exécutée par votre code applicatif : par exemple, le handler HTTP d'une route d'API peut représenter un span et celui-ci peut avoir d'autres span enfants comme un appel à une base de données, par exemple, - Une
trace
est un ensemble de spans : ces spans sont reliés par un identifianttrace_id
, automatiquement ajouté sur la création d'un premier span (qui n'a pas de parent).
Déclarons donc un handle HTTP avec un appel à une API, permettant de déclarer une trace avec deux spans (un parent et un enfant) :
import (
"context"
"net/http"
"go.opentelemetry.io/otel/codes"
)
// ...
var (
tracer = tracerProvider.Tracer("http-handler")
)
func ServeHTTP(writer http.ResponseWriter, request *http.Request) {
ctx, span := tracer.Start(
request.Context(),
"http-server: example handler",
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
semconv.HTTPMethodKey.String(request.Method),
),
)
// Handle your request here...
data := retrieveDataFromDatabase(ctx)
// Handle the next part of you query here...
span.End()
}
func retrieveDataFromDatabase(ctx context.Context) interface{} {
ctx, span = tracer.Start(
ctx,
"database: retrieve data",
trace.WithAttributes(
semconv.HTTPMethodKey.String(request.Method),
attribute.String("my-key", "my-value"),
),
)
// Query database
data, err := mydatabase.QuerySomething(ctx)
if err != nil {
span.SetStatus(codes.Error, err.Error())
}
span.End()
return data
}
Dans cet exemple, nous avons donc deux spans : un premier nommé http-server: example handler
et en enfant de ce span celui de l'appel à notre base de données : database: retrieve data
.
La parentalité se fait via le contexte : lors de la création de notre premier span, la librairie va définir deux clés dans le contexte pour y stocker le trace_id
et le span_id
du contexte parent. Ainsi, lors de la création d'un nouveau span, les informations stockés dans le contexte sont exploités pour définir la hiérarchie.
On notera la possibilité d'ajouter des attributs spécifiques à ces spans. La plupart des attributs principaux que vous pourrez définir sont déjà normalisés sous le package semconv
. Bien évidémment, vous pouvez aussi définir vos propres attributs.
Le span
peut être marqué en erreur si vous avez rencontrés une erreur.
Instrumentaliser les librairies tierces
Les librairies net/http
, les clients et serveurs gRPC
ou encore les clients de bases de données, d'outils de cache ou les SDK que vous pouvez utiliser sur vos projets (comme celui d'AWS par exemple) sont très certainement déjà instrumentalisés.
Vous pouvez regarder dans les repositories suffixés *-contrib
associés ici : https://github.com/open-telemetry?q=contrib.
Cependant, certaines instrumentalisations sont également disponibles sur d'autres repositories GitHub, n'hésitez donc pas à faire quelques recherches avant de vous lancer dans votre instrumentalisation.
Par exemple, j'ai eu l'occasion de travailler sur l'instrumentalisation de la librairie confluentinc/confluent-kafka-go ici : https://github.com/etf1/opentelemetry-go-contrib.
L'avantage de ces instrumentalisations sont qu'elles vous permettent de mettre en place rapidement des premières traces sur ces appels sans avoir trop de code à modifier de votre côté.
Par exemple, l'instrumentalisation du SDK AWS pour ajouter des traces à un client DynamoDB s'ajoute uniquement en ajoutant la ligne suivante :
import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws"
)
// ...
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
panic(err)
}
otelaws.AppendMiddlewares(&cfg.APIOptions, otelaws.WithTracerProvider(tracerProvider))
dynamodbClient := dynamodb.NewFromConfig(cfg)
Instrumentaliser ma propre librairie
Si vous souhaitez instrumentaliser une librairie, voici les quelques éléments à connaitre. Je vais prendre ici l'exemple de l'instrumentalisation faite dans ce producer Kafka.
Avant de rentrer dans le détail, voici un résumé des étapes à effectuer :
- Récupérer les informations stockées dans le message Kafka (si c'est un message consommé d'une brique précédente) afin de les stocker dans notre contexte Go
- Créer une span à partir du contexte Go et donc définir la span parente (encore une fois, s'il y en avait une)
- Mettre à jour les informations de la span nouvellement créée et mises à jour dans notre contexte Go dans le message Kafka
- Ainsi, lorsque le message Kafka sera consommé par une autre application dans le futur, il contiendra les informations de la span de provenance
Jettons maintenant un oeil aux interfaces de la librairie opentelemetry-go que nous devons respecter :
type TextMapCarrier interface {
// Get returns the value associated with the passed key.
Get(key string) string
// DO NOT CHANGE: any modification will not be backwards compatible and
// must never be done outside of a new major release.
// Set stores the key-value pair.
Set(key string, value string)
// DO NOT CHANGE: any modification will not be backwards compatible and
// must never be done outside of a new major release.
// Keys lists the keys stored in this carrier.
Keys() []string
}
Cette première interface TextMapCarrier
permet de définir et d'obtenir des attributs depuis votre contexte : autrement dit c'est principalement un moyen de stocker ou de récupérer les attributs trace_id
et span_id
sur votre objet (une requête HTTP, un message Kafka, ou autre).
Une fois votre objet wrappé par votre implémentation de TextMapCarrier
, il vous faudra alors l'utiliser dans le propagator via les méthodes suivantes :
type TextMapPropagator interface {
// Inject set cross-cutting concerns from the Context into the carrier.
Inject(ctx context.Context, carrier TextMapCarrier)
// DO NOT CHANGE: any modification will not be backwards compatible and
// must never be done outside of a new major release.
// Extract reads cross-cutting concerns from the carrier into a Context.
Extract(ctx context.Context, carrier TextMapCarrier) context.Context
// DO NOT CHANGE: any modification will not be backwards compatible and
// must never be done outside of a new major release.
// Fields returns the keys whose values are set with Inject.
Fields() []string
}
Concrêtement, à la réception d'une requête : il vous faut extraire les informations du contexte en utilisant la méthode Extract(...) context.Context
afin de déterminer si un span parent doit être utilisé. Vous récupérerez ainsi un contexte avec les données prêtes pour la création de votre span :
carrier := NewMessageCarrier(message)
ctx = otel.GetTextMapPropagator().Extract(ctx, carrier)
Vous pouvez alors créer un span
avec votre contexte comme vu précédemment puis re-injecter dans votre message Kafka le trace_id
et span_id
mis à jour :
ctx, span := tracer.Start(ctx, "produce")
otel.GetTextMapPropagator().Inject(ctx, carrier)
Il ne vous reste plus qu'à ajouter quelques attributs sur votre span, si vous le souhaitez :
span.SetAttributes(
semconv.MessagingSystemKey.String("kafka")
semconv.MessagingDestinationKindTopic,
semconv.MessagingDestinationKey.String(message.Topic),
)
Vous pouvez maintenant effectuer le traitement que vous avez à effectuer (dans notre cas produire le message Kafka) puis fermer votre span :
err := producer.Produce(message)
if err != nil {
span.SetStatus(codes.Error, err.Error())
}
span.End()
Et voilà, vous avez les premières billes pour vous première d'instrumentaliser vos librairies.
Associer des logs à mes traces
Nous avons maintenant des traces qui remontent : il ne nous reste plus qu'à associer nos logs applicatifs à la span associée.
Techniquement, c'est simple, il faut simplement remonter deux attributs avec le trace_id
et span_id
dans le format du log. Par exemple en JSON :
{
"timestamp": 1581385157.14429,
"message": "My log message",
"trace_id": "123456789123456789123456",
"span_id": "1234567891234567"
}
Afin que vos logs soient associés à vos traces, il vous faut simplement récupérer ces informations sur votre span comme suit :
logger = logger.With(
zap.String("span_id", span.SpanContext().SpanID().String()),
zap.String("trace_id", span.SpanContext().TraceID().String()),
)
// ...
logger.Info("Received response from my service", zap.String("data", data))
Cela permettra à l'agent collecteur de logs permettant de récupérer vos logs applicatifs et de les associer à un span : par dépendance, à votre application/version également.
Conclusion
OpenTelemetry est un outil vraiment intéressant à mettre en place, particulièrement si vous avez une architecture distribuée avec plusieurs micro-services : vous aurez une meilleure vision sur le workflow de vos requêtes applicatives.
La mise en place sur un projet déjà existant peut être un peu fastidieuse car il faut modifier une bonne portion de code mais vous pouvez y aller par étapes :
- Commencer par instrumentaliser les librairies afin d'avoir des informations sur les appels tiers,
- Ajouter petit à petit des traces dans votre code en commençant par les parties critiques,
- Relier vos logs à vos traces.
N'hésitez pas à me contacter si vous souhaitez avoir plus d'informations sur ce sujet !