🔧 feat: add OpenTelemetry instrumentation to persistence layer
- Added persistence telemetry configuration option (telemetry.persistence.enabled) - Created PersistenceTelemetryConfig struct for fine-grained control - Added GetPersistenceTelemetryEnabled() helper method - Implemented telemetry span creation in SQLite repository - Added OpenTelemetry instrumentation to key repository methods: - CreateUser: Tracks user creation with error recording - GetUserByUsername: Tracks queries with username attribute - Maintained backward compatibility - telemetry is optional and disabled by default - Updated all tests to pass config parameter to repository constructor - Added proper error recording and span attributes for observability Benefits: - Performance monitoring of database operations - Flamegraph generation capability for persistence layer - Distributed tracing across service boundaries - Configurable instrumentation for production vs development Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -12,14 +12,14 @@
|
|||||||
// @license.name MIT
|
// @license.name MIT
|
||||||
// @license.url https://opensource.org/licenses/MIT
|
// @license.url https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
// @host localhost:8080
|
// @host localhost:8080
|
||||||
// @BasePath /api
|
// @BasePath /api
|
||||||
// @schemes http https
|
// @schemes http https
|
||||||
//
|
//
|
||||||
// @securityDefinitions.apikey ApiKeyAuth
|
// @securityDefinitions.apikey ApiKeyAuth
|
||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description JWT authentication using Bearer token. Format: Bearer <token>
|
// @description JWT authentication using Bearer token. Format: Bearer <token>
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,17 @@ type LoggingConfig struct {
|
|||||||
|
|
||||||
// TelemetryConfig holds OpenTelemetry-related configuration
|
// TelemetryConfig holds OpenTelemetry-related configuration
|
||||||
type TelemetryConfig struct {
|
type TelemetryConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
|
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
|
||||||
ServiceName string `mapstructure:"service_name"`
|
ServiceName string `mapstructure:"service_name"`
|
||||||
Insecure bool `mapstructure:"insecure"`
|
Insecure bool `mapstructure:"insecure"`
|
||||||
Sampler SamplerConfig `mapstructure:"sampler"`
|
Sampler SamplerConfig `mapstructure:"sampler"`
|
||||||
|
Persistence PersistenceTelemetryConfig `mapstructure:"persistence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistenceTelemetryConfig holds persistence layer telemetry configuration
|
||||||
|
type PersistenceTelemetryConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIConfig holds API version configuration
|
// APIConfig holds API version configuration
|
||||||
@@ -107,6 +113,7 @@ func LoadConfig() (*Config, error) {
|
|||||||
v.SetDefault("telemetry.insecure", true)
|
v.SetDefault("telemetry.insecure", true)
|
||||||
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
|
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
|
||||||
v.SetDefault("telemetry.sampler.ratio", 1.0)
|
v.SetDefault("telemetry.sampler.ratio", 1.0)
|
||||||
|
v.SetDefault("telemetry.persistence.enabled", false)
|
||||||
|
|
||||||
// API defaults
|
// API defaults
|
||||||
v.SetDefault("api.v2_enabled", false)
|
v.SetDefault("api.v2_enabled", false)
|
||||||
@@ -215,6 +222,11 @@ func (c *Config) GetServiceName() string {
|
|||||||
return c.Telemetry.ServiceName
|
return c.Telemetry.ServiceName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPersistenceTelemetryEnabled returns whether persistence layer telemetry is enabled
|
||||||
|
func (c *Config) GetPersistenceTelemetryEnabled() bool {
|
||||||
|
return c.Telemetry.Enabled && c.Telemetry.Persistence.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
// GetTelemetryInsecure returns whether to use insecure connection
|
// GetTelemetryInsecure returns whether to use insecure connection
|
||||||
func (c *Config) GetTelemetryInsecure() bool {
|
func (c *Config) GetTelemetryInsecure() bool {
|
||||||
return c.Telemetry.Insecure
|
return c.Telemetry.Insecure
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserS
|
|||||||
dbPath := "file::memory:?cache=shared"
|
dbPath := "file::memory:?cache=shared"
|
||||||
|
|
||||||
// Create user repository
|
// Create user repository
|
||||||
repo, err := user.NewSQLiteRepository(dbPath)
|
repo, err := user.NewSQLiteRepository(dbPath, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to create user repository: %w", err)
|
return nil, nil, fmt.Errorf("failed to create user repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dance-lessons-coach/pkg/config"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -16,14 +20,18 @@ import (
|
|||||||
|
|
||||||
// SQLiteRepository implements UserRepository using SQLite
|
// SQLiteRepository implements UserRepository using SQLite
|
||||||
type SQLiteRepository struct {
|
type SQLiteRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dbPath string
|
dbPath string
|
||||||
|
config *config.Config
|
||||||
|
spanPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSQLiteRepository creates a new SQLite repository
|
// NewSQLiteRepository creates a new SQLite repository
|
||||||
func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
|
func NewSQLiteRepository(dbPath string, config *config.Config) (*SQLiteRepository, error) {
|
||||||
repo := &SQLiteRepository{
|
repo := &SQLiteRepository{
|
||||||
dbPath: dbPath,
|
dbPath: dbPath,
|
||||||
|
config: config,
|
||||||
|
spanPrefix: "user.repo.",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.initializeDatabase(); err != nil {
|
if err := repo.initializeDatabase(); err != nil {
|
||||||
@@ -70,8 +78,17 @@ func (r *SQLiteRepository) initializeDatabase() error {
|
|||||||
|
|
||||||
// CreateUser creates a new user in the database
|
// CreateUser creates a new user in the database
|
||||||
func (r *SQLiteRepository) CreateUser(ctx context.Context, user *User) error {
|
func (r *SQLiteRepository) CreateUser(ctx context.Context, user *User) error {
|
||||||
|
// Create telemetry span
|
||||||
|
ctx, span := r.createSpan(ctx, "create_user")
|
||||||
|
if span != nil {
|
||||||
|
defer span.End()
|
||||||
|
}
|
||||||
|
|
||||||
result := r.db.WithContext(ctx).Create(user)
|
result := r.db.WithContext(ctx).Create(user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
if span != nil {
|
||||||
|
span.RecordError(result.Error)
|
||||||
|
}
|
||||||
return fmt.Errorf("failed to create user: %w", result.Error)
|
return fmt.Errorf("failed to create user: %w", result.Error)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -79,12 +96,22 @@ func (r *SQLiteRepository) CreateUser(ctx context.Context, user *User) error {
|
|||||||
|
|
||||||
// GetUserByUsername retrieves a user by username
|
// GetUserByUsername retrieves a user by username
|
||||||
func (r *SQLiteRepository) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
func (r *SQLiteRepository) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
|
// Create telemetry span
|
||||||
|
ctx, span := r.createSpan(ctx, "get_user_by_username")
|
||||||
|
if span != nil {
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(attribute.String("username", username))
|
||||||
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
result := r.db.WithContext(ctx).Where("username = ?", username).First(&user)
|
result := r.db.WithContext(ctx).Where("username = ?", username).First(&user)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if span != nil {
|
||||||
|
span.RecordError(result.Error)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to get user by username: %w", result.Error)
|
return nil, fmt.Errorf("failed to get user by username: %w", result.Error)
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
@@ -172,3 +199,15 @@ func (r *SQLiteRepository) Close() error {
|
|||||||
}
|
}
|
||||||
return sqlDB.Close()
|
return sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createSpan creates a new telemetry span if persistence telemetry is enabled
|
||||||
|
func (r *SQLiteRepository) createSpan(ctx context.Context, operation string) (context.Context, trace.Span) {
|
||||||
|
if r.config == nil || !r.config.GetPersistenceTelemetryEnabled() {
|
||||||
|
return ctx, trace.SpanFromContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new span with the operation name
|
||||||
|
spanName := r.spanPrefix + operation
|
||||||
|
tr := otel.Tracer("user-repository")
|
||||||
|
return tr.Start(ctx, spanName)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,17 +6,31 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"dance-lessons-coach/pkg/config"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// createTestConfig creates a test configuration with telemetry disabled
|
||||||
|
func createTestConfig() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Telemetry: config.TelemetryConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Persistence: config.PersistenceTelemetryConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSQLiteRepository(t *testing.T) {
|
func TestSQLiteRepository(t *testing.T) {
|
||||||
t.Run("CRUD operations", func(t *testing.T) {
|
t.Run("CRUD operations", func(t *testing.T) {
|
||||||
// Create a temporary database
|
// Create a temporary database
|
||||||
dbPath := "test_db.sqlite"
|
dbPath := "test_db.sqlite"
|
||||||
defer os.Remove(dbPath)
|
defer os.Remove(dbPath)
|
||||||
|
|
||||||
repo, err := NewSQLiteRepository(dbPath)
|
cfg := createTestConfig()
|
||||||
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer repo.Close()
|
defer repo.Close()
|
||||||
|
|
||||||
@@ -92,7 +106,8 @@ func TestAuthService(t *testing.T) {
|
|||||||
dbPath := "test_auth_db.sqlite"
|
dbPath := "test_auth_db.sqlite"
|
||||||
defer os.Remove(dbPath)
|
defer os.Remove(dbPath)
|
||||||
|
|
||||||
repo, err := NewSQLiteRepository(dbPath)
|
cfg := createTestConfig()
|
||||||
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer repo.Close()
|
defer repo.Close()
|
||||||
|
|
||||||
@@ -162,7 +177,8 @@ func TestPasswordResetService(t *testing.T) {
|
|||||||
dbPath := "test_reset_db.sqlite"
|
dbPath := "test_reset_db.sqlite"
|
||||||
defer os.Remove(dbPath)
|
defer os.Remove(dbPath)
|
||||||
|
|
||||||
repo, err := NewSQLiteRepository(dbPath)
|
cfg := createTestConfig()
|
||||||
|
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer repo.Close()
|
defer repo.Close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user