Всем привет, этой мой первый пост на данной платформе, прошу любить и жаловать.

Трассировка — это важный инструмент для мониторинга и диагностики микросервисов. Она позволяет понять, как запросы проходят через систему, где возникают узкие места, и как взаимодействуют различные компоненты приложения. В этой статье я расскажу про свой опыт, как интегрировал трассировку в сервис на 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"
	"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. В создание спанов главное правильно передавать контексты, иначе в админке увидите не точную картину ваших трейсов, не будет вложенности. Я к сожалению потратил очень много времени чтобы выявить это.

Данный пост написан для обмена опытом, мой опыт - это лишь один из возможных подходов, и приглашаю Вас к конструктивному обсуждению.

Комментарии (0)