Трассировка на Go
Всем привет, этой мой первый пост на данной платформе, прошу любить и жаловать.
Трассировка — это важный инструмент для мониторинга и диагностики микросервисов. Она позволяет понять, как запросы проходят через систему, где возникают узкие места, и как взаимодействуют различные компоненты приложения. В этой статье я расскажу про свой опыт, как интегрировал трассировку в сервис на Go, использующий GORM.
1. Основы трассировки с OpenTelemetry
OpenTelemetry — это популярная платформа для сбора, обработки и экспорта метрик, логов и трассировок. Пример настройки OpenTelemetry:
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(traceConfig.Url)))
if err != nil {
...
}
tp := tracesdk.NewTracerProvider(
tracesdk.WithBatcher(exp),
tracesdk.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(traceConfig.ServiceName),
attribute.String("environment", "production"),
attribute.Int64("ID", 1),
)),
)
2. Интеграция с GORM
Для интеграции трассировки с GORM важно перехватывать события до и после выполнения SQL-запросов. Это позволяет собирать информацию о времени выполнения запросов, количестве затронутых строк и возможных ошибках.
Пример плагина для GORM:
func MiddleWareGormTrace() gorm.Plugin {
return &GormTracing{}
}
type GormTracing struct {
}
func (g *GormTracing) Name() string {
return ""
}
func (p *GormTracing) Initialize(db *gorm.DB) error {
tracer := tracer.TraceClient
if tracer == nil || !tracer.IsEnabled {
return nil
}
db.Callback().Create().Before("gorm:before_create").Register("gormotel:before_create", p.before(tracer))
db.Callback().Query().Before("gorm:before_query").Register("gormotel:before_query", p.before(tracer))
db.Callback().Delete().Before("gorm:before_delete").Register("gormotel:before_delete", p.before(tracer))
db.Callback().Update().Before("gorm:before_update").Register("gormotel:before_update", p.before(tracer))
db.Callback().Row().Before("gorm:before_row").Register("gormotel:before_row", p.before(tracer))
db.Callback().Raw().Before("gorm:before_raw").Register("gormotel:before_raw", p.before(tracer))
db.Callback().Create().After("gorm:after_create").Register("gormotel:after_create", p.after)
db.Callback().Query().After("gorm:after_query").Register("gormotel:after_query", p.after)
db.Callback().Delete().After("gorm:after_delete").Register("gormotel:after_delete", p.after)
db.Callback().Update().After("gorm:after_update").Register("gormotel:after_update", p.after)
db.Callback().Row().After("gorm:after_row").Register("gormotel:after_row", p.after)
db.Callback().Raw().After("gorm:after_raw").Register("gormotel:after_raw", p.after)
return nil
}
func (p *PluginTrace) before(tracer *tracer.Tracer) func(*gorm.DB) {
return func(db *gorm.DB) {
ctx, span := tracer.CreateSpan(db.Statement.Context, "[DB]")
db.InstanceSet("otel:span", span)
db.Statement.Context = ctx
}
}
func (p *PluginTrace) after(db *gorm.DB) {
if spanVal, ok := db.InstanceGet("otel:span"); ok {
if span, ok := spanVal.(trace.Span); ok {
defer span.End()
span.SetAttributes(
attribute.String(span2.AttributeDBStatement, db.Statement.SQL.String()),
attribute.String(span2.AttributeDBTable, db.Statement.Table),
attribute.Int64(span2.AttributeDbRowsAffected, db.RowsAffected),
)
if db.Error != nil {
span.RecordError(db.Error)
span.SetStatus(trace2.StatusCodeError, db.Error.Error())
}
}
}
}
Вот пример как внедрить в GORM:
dbClient, err := database.GetGormConnection(
database.DbConfig{
Driver: database.MySql,
Host: app.dbConfig.Host,
User: app.dbConfig.User,
Password: app.dbConfig.Password,
Db: app.dbConfig.Db,
Port: app.dbConfig.Port,
SslMode: false,
Logging: app.dbConfig.Logging,
MaxOpenConnections: app.dbConfig.MaxOpenConnections,
MaxIdleConnections: app.dbConfig.MaxIdleConnections,
},
)
if err != nil {
return err
}
dbClient.Use(gormtracing.MiddleWareGormTrace())
3. Обработка HTTP-запросов
Трассировка HTTP-запросов позволяет отслеживать путь запроса через все слои приложения. Для этого важно использовать middleware, который будет создавать спан для каждого входящего запроса и записывать важные метаданные. При этом не стоит использовать данный middleware на все запросы, на моем горьком опыте были сервисы которые записывали health-чекеры.
Пример middleware для Gin:
func (t *Tracer) MiddleWareTrace() gin.HandlerFunc {
return func(c *gin.Context) {
if t == nil || !t.cfg.IsTraceEnabled {
c.Next()
return
}
parentCtx, span := t.CreateSpan(c.Request.Context(), "["+c.Request.Method+"] "+c.FullPath())
defer span.End()
c.Request = c.Request.WithContext(parentCtx)
c.Next()
// Обработка ошибок для сервисов использующих sdk
excep := c.Keys["exception"]
switch v := excep.(type) {
case *exception.AppException:
span.SetAttributes(attribute.Int(span2.AttributeRespHttpCode, v.Code))
if v.Error != nil {
span.SetAttributes(attribute.String(span2.AttributeRespErrMsg, v.Error.Error()))
}
default:
span.SetAttributes(attribute.Int(span2.AttributeRespHttpCode, c.Writer.Status()))
}
}
}
Вот пример как внедрить MW
v1 := router.Group("/banner/v1")
v1.Use(tracer.MiddleWareTrace())
Вот полный код клиента трассировки:
Данную переменную var TraceClient *Tracer вытащил в глобал, только потому, что есть реализация HTTP-Builder-а , где на каждый запрос я создаю свой спан. У нас в Go сервисах реализована слоистая архитектура, и пришлось бы данного клиента прокидывать в каждый слой для трассировки http запросов в сторонние сервис
package tracer
import (
"bytes"
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/gookit/goutil/netutil/httpctype"
"github.com/gookit/goutil/netutil/httpheader"
"gitlab.almanit.kz/jmart/gosdk/pkg/config"
"gitlab.almanit.kz/jmart/gosdk/pkg/exception"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
tracesdk "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.7.0"
trace2 "go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
"io"
"net/http"
"strings"
)
var TraceClient *Tracer
const AttributeReqBody = "request.body"
const (
AttributeRespHttpCode = "http.status_code"
AttributeRespErrMsg = "error.message"
)
type TraceConfig struct {
IsTraceEnabled bool `mapstructure:"TRACE_IS_ENABLED"`
Url string `mapstructure:"TRACE_URL"`
ServiceName string `mapstructure:"TRACE_SERVICE_NAME"`
IsHttpBodyEnabled bool `mapstructure:"TRACE_IS_HTTP_BODY_ENABLED"`
}
type Tracer struct {
tp *tracesdk.TracerProvider
cfg *TraceConfig
IsEnabled bool
ServiceName string
}
// InitTraceClient - создание клиента трассировки
func InitTraceClient() (*Tracer, error) {
t := &Tracer{}
// config init
if err := t.initTraceConfig(); err != nil {
return nil, err
}
if !t.cfg.IsTraceEnabled {
return t, nil
}
// Create the Jaeger exporter
exp, err := jaeger.New(
jaeger.WithCollectorEndpoint(
jaeger.WithEndpoint(t.cfg.Url),
),
)
if err != nil {
return nil, err
}
tp := tracesdk.NewTracerProvider(
//tracesdk.WithSampler(),
// Always be sure to batch in production.
tracesdk.WithBatcher(exp),
// Record information about this application in a Resource.
tracesdk.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(t.cfg.ServiceName),
//attribute.String("environment", "development"),
//attribute.Int64("ID", 1),
)),
)
otel.SetTracerProvider(tp)
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
// error handler
}))
t.tp = tp
TraceClient = t
return t, nil
}
// Shutdown -
func (t *Tracer) Shutdown(ctx context.Context) error {
fmt.Println("shutdown")
return t.tp.Shutdown(ctx)
}
// InjectHttpTraceId - записывает trace id в запрос, требует *http.Request
func (t *Tracer) InjectHttpTraceId(ctx context.Context, req *http.Request) {
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
}
// MiddleWareTrace - мидлвар который записывает трассировку
// при этом контекст спана записывается в c.Request . В хэндлере рекомендуется передавать ctx.Request.Context() в слой ниже, или другую функцию
func (t *Tracer) MiddleWareTrace() gin.HandlerFunc {
return func(c *gin.Context) {
if t == nil || !t.cfg.IsTraceEnabled {
c.Next()
return
}
parentCtx, span := t.CreateSpan(c.Request.Context(), "["+c.Request.Method+"] "+c.FullPath(), "middleware")
defer span.End()
// парсинг body
if t.cfg.IsHttpBodyEnabled {
// нет смысла копировать тело запроса при наличии файла
if !strings.HasPrefix(c.GetHeader(httpheader.ContentType), httpctype.MIMEDataForm) {
bodyBytes, _ := io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
span.SetAttributes(attribute.String(AttributeReqBody, string(bodyBytes)))
}
}
c.Request = c.Request.WithContext(parentCtx)
c.Next()
// парсинг ошибок
{
excep := c.Keys["exception"]
switch v := excep.(type) {
case *exception.AppException:
span.SetAttributes(attribute.Int(AttributeRespHttpCode, v.Code))
if v.Error != nil {
span.SetAttributes(attribute.String(AttributeRespErrMsg, v.Error.Error()))
}
default:
span.SetAttributes(attribute.Int(AttributeRespHttpCode, c.Writer.Status()))
}
}
}
}
// CreateSpan - Создает родительский спан,и возвращает контекст, этот контекст нужен для дочернего спана.
// В случае если в ctx нет контекста родителя то создается контекст родителя
// Не забыть вызывать span.End()
func (t *Tracer) CreateSpan(ctx context.Context, name string, fun string) (context.Context, trace2.Span) {
if t == nil || t.tp == nil {
return context.Background(), noop.Span{}
}
return t.tp.Tracer(t.ServiceName).Start(ctx, name)
}
// CreateSpanWithCustomTraceId - экспериментальный метод, создаем спан на основе кастомного трайс айди
func (t *Tracer) CreateSpanWithCustomTraceId(ctx context.Context, traceId, name string) (context.Context, trace2.Span, error) {
tId, err := trace2.TraceIDFromHex(traceId)
if err != nil {
return nil, noop.Span{}, err
}
spanContext := trace2.NewSpanContext(trace2.SpanContextConfig{
TraceID: tId,
})
ctx1 := trace2.ContextWithSpanContext(ctx, spanContext)
ctx1, span := t.tp.Tracer(t.ServiceName).Start(ctx1, name)
return ctx1, span, nil
}
// initTraceConfig - инициализирует конфиг трассировки, читает из файла .env переменки
func (t *Tracer) initTraceConfig() error {
if err := config.ReadEnv(); err != nil {
return err
}
traceCfg := &TraceConfig{}
err := config.InitConfig(traceCfg)
if err != nil {
return err
}
t.cfg = traceCfg
t.ServiceName = traceCfg.ServiceName
t.IsEnabled = traceCfg.IsTraceEnabled
return nil
}
4. Какие лучшие практики для продакшн-среды выявил:
Когда трассировка интегрирована и работает, важно учитывать следующие моменты для использования в продакшн-среде:
Минимизируйте нагрузку: Используйте батчинг и асинхронную отправку данных, чтобы минимизировать влияние на производительность приложения.
Соблюдайте конфиденциальность данных: Не записывайте чувствительные данные в атрибуты или логи трассировки.
Регулярный мониторинг: Следите за объемом данных, отправляемых на трассировку, чтобы избежать избыточной генерации данных и переполнения системы мониторинга.
Установите ограничения на количество данных: Ограничьте глубину и количество трассировок, особенно для высоконагруженных сервисов. На продакшн-среде мы реализовали запись трейсов 1 из 5. То есть только 1 трейс из 5 запишется в базу, остальные игнорируем.
Заключение
Трассировка — это мощный инструмент для мониторинга микросервисов. Данным инструментом повысили наблюдаемость наших сервисов, но и упростили диагностику и устранение неполадок.
P.S. В создание спанов главное правильно передавать контексты, иначе в админке увидите не точную картину ваших трейсов, не будет вложенности. Я к сожалению потратил очень много времени чтобы выявить это.
Данный пост написан для обмена опытом, мой опыт — это лишь один из возможных подходов, и приглашаю Вас к конструктивному обсуждению.