Files
honeyDueAPI/cmd/api/main.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

254 lines
7.8 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/database"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/monitoring"
"github.com/treytartt/honeydue-api/internal/push"
"github.com/treytartt/honeydue-api/internal/router"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/pkg/utils"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Printf("Failed to load configuration: %v\n", err)
os.Exit(1)
}
// Initialize basic logger first (will be enhanced after Redis connects)
utils.InitLogger(cfg.Server.Debug)
// Initialize i18n
if err := i18n.Init(); err != nil {
log.Warn().Err(err).Msg("Failed to initialize i18n - using English only")
} else {
log.Info().Strs("languages", i18n.SupportedLanguages).Msg("i18n initialized")
}
log.Info().
Bool("debug", cfg.Server.Debug).
Int("port", cfg.Server.Port).
Str("db_host", cfg.Database.Host).
Int("db_port", cfg.Database.Port).
Str("db_name", cfg.Database.Database).
Str("db_user", cfg.Database.User).
Str("redis_url", config.MaskURLCredentials(cfg.Redis.URL)).
Msg("Starting HoneyDue API server")
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
// a no-op tracer so call sites can use otel.Tracer() unconditionally.
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-api",
Environment: deploymentEnvironment(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(),
})
if err != nil {
log.Error().Err(err).Msg("tracing init failed — continuing without traces")
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := tracingShutdown(shutdownCtx); err != nil {
log.Warn().Err(err).Msg("tracing shutdown error")
}
}()
// Connect to database (retry with backoff)
var db *gorm.DB
var dbErr error
for i := 0; i < 3; i++ {
db, dbErr = database.Connect(&cfg.Database, cfg.Server.Debug)
if dbErr == nil {
break
}
log.Warn().Err(dbErr).Int("attempt", i+1).Msg("Failed to connect to database, retrying...")
time.Sleep(time.Duration(i+1) * time.Second)
}
if dbErr != nil {
log.Error().Err(dbErr).Msg("Failed to connect to database - API will start but database operations will fail")
} else {
defer database.Close()
// Run database migrations only if connected.
// MigrateWithLock serialises parallel replica starts via a Postgres
// advisory lock so concurrent AutoMigrate calls don't race on DDL.
if err := database.MigrateWithLock(); err != nil {
log.Error().Err(err).Msg("Failed to run database migrations")
}
}
// Connect to Redis (optional - don't fail if unavailable)
var cache *services.CacheService
cache, err = services.NewCacheService(&cfg.Redis)
if err != nil {
log.Warn().Err(err).Msg("Failed to connect to Redis - caching disabled")
cache = nil
} else {
defer cache.Close()
if database.SeedInitialDataApplied {
if err := cache.InvalidateSeededData(context.Background()); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate seeded data cache after initial seed")
} else {
log.Info().Msg("Invalidated seeded_data cache after initial seed migration")
}
}
}
// Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service
if cache != nil {
monitoringService = monitoring.NewService(monitoring.Config{
Process: "api",
RedisClient: cache.Client(),
DB: db, // Pass database for enable_monitoring setting sync
})
// Reinitialize logger with monitoring writer
utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter())
// Start stats collection
monitoringService.Start()
defer monitoringService.Stop()
log.Info().
Bool("log_capture_enabled", monitoringService.IsEnabled()).
Msg("Monitoring service initialized")
}
// Initialize email service
var emailService *services.EmailService
log.Info().
Str("email_host", cfg.Email.Host).
Str("email_user", cfg.Email.User).
Str("email_from", cfg.Email.From).
Int("email_port", cfg.Email.Port).
Msg("Email config loaded")
if cfg.Email.Host != "" && cfg.Email.User != "" {
emailService = services.NewEmailService(&cfg.Email, cfg.Features.EmailEnabled)
log.Info().
Str("host", cfg.Email.Host).
Msg("Email service initialized")
} else {
log.Warn().
Str("host", cfg.Email.Host).
Str("user", cfg.Email.User).
Msg("Email service not configured - emails will not be sent")
}
// Initialize storage service for file uploads (local filesystem or S3-compatible)
var storageService *services.StorageService
if cfg.Storage.UploadDir != "" || cfg.Storage.IsS3() {
storageService, err = services.NewStorageService(&cfg.Storage)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize storage service - uploads disabled")
} else {
// Initialize file encryption at rest if configured
if cfg.Storage.EncryptionKey != "" {
encSvc, encErr := services.NewEncryptionService(cfg.Storage.EncryptionKey)
if encErr != nil {
log.Error().Err(encErr).Msg("Failed to initialize encryption service - files will NOT be encrypted")
} else {
storageService.SetEncryptionService(encSvc)
log.Info().Msg("File encryption at rest enabled")
}
}
}
}
// Initialize PDF service for report generation
pdfService := services.NewPDFService()
log.Info().Msg("PDF service initialized")
// Initialize push notification client (APNs + FCM)
var pushClient *push.Client
pushClient, err = push.NewClient(&cfg.Push, cfg.Features.PushEnabled)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize push client - push notifications disabled")
} else {
log.Info().
Bool("ios_enabled", pushClient.IsIOSEnabled()).
Bool("android_enabled", pushClient.IsAndroidEnabled()).
Msg("Push notification client initialized")
}
// Setup router with dependencies (includes admin panel at /admin)
deps := &router.Dependencies{
DB: db,
Cache: cache,
Config: cfg,
EmailService: emailService,
PDFService: pdfService,
PushClient: pushClient,
StorageService: storageService,
MonitoringService: monitoringService,
}
e := router.SetupRouter(deps)
// Create HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: e,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
log.Info().
Str("addr", srv.Addr).
Msg("HTTP server listening")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Failed to start HTTP server")
}
}()
// Wait for interrupt signal for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msg("Shutting down server...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal().Err(err).Msg("Server forced to shutdown")
}
log.Info().Msg("Server exited")
}
// deploymentEnvironment turns the boolean Debug flag into the conventional
// environment label spans get tagged with.
func deploymentEnvironment(debug bool) string {
if env := os.Getenv("DEPLOYMENT_ENVIRONMENT"); env != "" {
return env
}
if debug {
return "dev"
}
return "prod"
}