Skip to content

Commit b92cdbf

Browse files
wim07101993mridang
andauthored
feat: added logging and otel (#232)
This pull request adds structured logging and OpenTelemetry-based instrumentation. The configuration system is extended to support detailed instrumentation settings, and several new dependencies are added to support these features. Parts of this have been taken from the current otel implementation in zitadel. --------- Co-authored-by: Mridang Agarwalla <mridang.agarwalla@gmail.com>
1 parent 6f8dd2d commit b92cdbf

43 files changed

Lines changed: 2182 additions & 151 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ jobs:
265265
# ran, and services wired; the generous timeout covers a cold Maven download.
266266
ready=0
267267
for i in $(seq 1 240); do
268-
if grep -q "server listening on" server.log; then ready=1; break; fi
268+
if grep -q "server listening for requests" server.log; then ready=1; break; fi
269269
if ! kill -0 "$pid" 2>/dev/null; then
270270
echo "::error::server exited before becoming ready"; cat server.log; exit 1
271271
fi

cmd/server/config.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import (
44
"time"
55

66
"github.com/zitadel/nextgen/internal/crypto"
7+
"github.com/zitadel/nextgen/internal/instrumentation"
78
"github.com/zitadel/nextgen/internal/service"
89
"github.com/zitadel/nextgen/internal/storage/database"
910
)
1011

12+
const Name = "zitadel/backend/v3/instrumentation/tracing"
13+
1114
type Config struct {
12-
Server ServerConfig `mapstructure:"server"`
13-
Database database.Config `mapstructure:"database"`
14-
PasswordHasher crypto.HashConfig `mapstructure:"password_hasher"`
15-
Schema SchemaConfig `mapstructure:"schema"`
16-
Session service.SessionConfig `mapstructure:"session"`
15+
Server ServerConfig `mapstructure:"server"`
16+
Database database.Config `mapstructure:"database"`
17+
PasswordHasher crypto.HashConfig `mapstructure:"password_hasher"`
18+
Schema SchemaConfig `mapstructure:"schema"`
19+
Session service.SessionConfig `mapstructure:"session"`
20+
Instrumentation instrumentation.Config `mapstructure:"instrumentation"`
1721
}
1822

1923
func (c Config) Validate() error {

cmd/server/server.go

Lines changed: 153 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"encoding/hex"
66
"errors"
77
"fmt"
8-
"log"
8+
"log/slog"
99
"net/http"
1010
"net/url"
1111
"os"
@@ -19,12 +19,17 @@ import (
1919
"github.com/ianlancetaylor/jsonschema"
2020
"github.com/spf13/cobra"
2121
"github.com/spf13/viper"
22+
slogctx "github.com/veqryn/slog-context"
2223
oasapi "github.com/zitadel/nextgen/api/generated"
2324
"github.com/zitadel/nextgen/internal/api"
25+
"github.com/zitadel/nextgen/internal/api/middleware"
2426
"github.com/zitadel/nextgen/internal/bootstrap/users"
2527
"github.com/zitadel/nextgen/internal/crypto"
2628
"github.com/zitadel/nextgen/internal/domain"
2729
"github.com/zitadel/nextgen/internal/domain/idgen"
30+
"github.com/zitadel/nextgen/internal/instrumentation"
31+
"github.com/zitadel/nextgen/internal/instrumentation/zlog"
32+
"github.com/zitadel/nextgen/internal/instrumentation/zotel"
2833
"github.com/zitadel/nextgen/internal/secrets"
2934
"github.com/zitadel/nextgen/internal/service"
3035
"github.com/zitadel/nextgen/internal/staticui/console"
@@ -34,6 +39,8 @@ import (
3439
"github.com/zitadel/nextgen/internal/storage/database/dialect/postgres/embedded"
3540
"github.com/zitadel/nextgen/internal/storage/database/repository"
3641
"github.com/zitadel/oidc/v3/pkg/op"
42+
"go.opentelemetry.io/contrib/bridges/otelslog"
43+
"go.opentelemetry.io/otel/log"
3744
)
3845

3946
func NewCommand() *cobra.Command {
@@ -48,13 +55,7 @@ func NewCommand() *cobra.Command {
4855
if err != nil {
4956
return err
5057
}
51-
52-
pool, err := startDatabase(cmd.Context(), cfg)
53-
if err != nil {
54-
return err
55-
}
56-
57-
return run(cmd.Context(), cfg, pool, userFiles)
58+
return run(cmd.Context(), cfg, userFiles)
5859
},
5960
}
6061

@@ -64,61 +65,61 @@ func NewCommand() *cobra.Command {
6465
return cmd
6566
}
6667

67-
func startDatabase(ctx context.Context, cfg Config) (database.Pool, error) {
68-
connector, err := buildDatabaseConnector(cfg)
69-
if err != nil {
70-
return nil, err
71-
}
72-
pool, err := connector.Connect(ctx)
73-
if err != nil {
74-
return nil, err
75-
}
76-
err = pool.Migrate(ctx)
68+
func run(ctx context.Context, cfg Config, userFiles []string) error {
69+
var err error
70+
sfs := &ShutdownFuncs{}
71+
defer func() {
72+
if err != nil {
73+
slog.Error("run error", slogctx.Err(err))
74+
}
75+
err = sfs.Exec(context.WithoutCancel(ctx))
76+
if err != nil {
77+
slog.Error("shutdown error", slogctx.Err(err))
78+
os.Exit(1)
79+
return
80+
}
81+
slog.Info("shut down application")
82+
}()
83+
84+
slog.Info("building server")
85+
86+
metrics, err := zotel.NewOtelMetrics(ctx, zotel.MetricsConfig{
87+
ServiceName: cfg.Instrumentation.ServiceName,
88+
TraceIdFraction: cfg.Instrumentation.Trace.Fraction,
89+
TraceExporter: cfg.Instrumentation.Trace.Exporter,
90+
MetricExporter: cfg.Instrumentation.Metric.Exporter,
91+
LogExporter: cfg.Instrumentation.Log.Exporter,
92+
})
7793
if err != nil {
78-
return nil, err
94+
return fmt.Errorf("failed to create otel metrics: %w", err)
7995
}
80-
return pool, nil
81-
}
96+
sfs.Add(metrics.Shutdown)
8297

83-
func buildDatabaseConnector(cfg Config) (database.Connector, error) {
84-
if len(cfg.Database.Raw) == 0 {
85-
options := embeddedPostgresOptions(cfg.Server.DataDir)
86-
log.Printf("no database dialect configured, starting embedded postgres in %s", filepath.Dir(options.DataPath))
87-
return embedded.NewConnector(options), nil
88-
}
89-
return cfg.Database.Build()
90-
}
98+
setUpLogging(cfg.Instrumentation.Log, metrics.LoggerProvider())
9199

92-
func embeddedPostgresOptions(dataDir string) embedded.Options {
93-
root := filepath.Join(dataDir, "embedded-postgres")
94-
return embedded.Options{
95-
RuntimePath: filepath.Join(root, "runtime"),
96-
DataPath: filepath.Join(root, "data"),
97-
CachePath: filepath.Join(root, "cache"),
98-
LogPath: filepath.Join(root, "postgres.log"),
99-
Logger: os.Stdout,
100+
pool, err := startDatabase(ctx, cfg)
101+
if err != nil {
102+
return err
100103
}
101-
}
102-
103-
func run(ctx context.Context, cfg Config, pool database.Pool, userFiles []string) error {
104-
defer func() {
105-
if err := pool.Close(context.Background()); err != nil {
106-
log.Printf("close database pool: %v", err)
104+
sfs.Add(func(ctx context.Context) error {
105+
if err := pool.Close(ctx); err != nil {
106+
return fmt.Errorf("failed close database pool: %w", err)
107107
}
108-
}()
108+
return nil
109+
})
109110

110111
crypter, err := buildCrypter(cfg.Server.EncryptionKey)
111112
if err != nil {
112-
return err
113+
return fmt.Errorf("failed to create Crypter: %w", err)
113114
}
114115

115116
passwordHasher, err := cfg.PasswordHasher.NewHasher()
116117
if err != nil {
117-
return fmt.Errorf("build password hasher: %w", err)
118+
return fmt.Errorf("failed to build password hasher: %w", err)
118119
}
119120

120121
if err := users.Import(ctx, pool, passwordHasher, users.DialectFromConfig(cfg.Database.Raw), userFiles); err != nil {
121-
return fmt.Errorf("bootstrap users: %w", err)
122+
return fmt.Errorf("failed to bootstrap users: %w", err)
122123
}
123124

124125
// ── Repositories ─────────────────
@@ -136,22 +137,23 @@ func run(ctx context.Context, cfg Config, pool database.Pool, userFiles []string
136137
// ── Schema Stuff ─────────────────
137138
schemaCache, err := lru.New2Q[string, *jsonschema.Schema](cfg.Schema.LRUCacheSize)
138139
if err != nil {
139-
return fmt.Errorf("build schema cache: %w", err)
140+
return fmt.Errorf("failed to build schema cache: %w", err)
140141
}
142+
141143
var builtinPublicBase *url.URL
142144
if cfg.Schema.BuiltinPublicBase != "" {
143145
builtinPublicBase, err = url.Parse(cfg.Schema.BuiltinPublicBase)
144146
if err != nil {
145-
return fmt.Errorf("parse builtin public base: %w", err)
147+
return fmt.Errorf("failed to parse builtin public base: %w", err)
146148
}
147149
}
148-
schemaResolverWithHTTP := domain.NewJSONSchemaResolver(schemaRepo, schemaCache, 10, 1000_000, &http.Client{}, builtinPublicBase)
149150

151+
schemaResolverWithHTTP := domain.NewJSONSchemaResolver(schemaRepo, schemaCache, 10, 1000_000, &http.Client{}, builtinPublicBase)
150152
// storageSchemaResolver without an HTTP client to fetch tenant schemas from the cache/storage
151153
storageSchemaResolver := domain.NewJSONSchemaResolver(schemaRepo, schemaCache, 10, 1000_000, nil, builtinPublicBase)
152154
schemaValidator, err := domain.NewSchemaValidator(builtinPublicBase.String())
153155
if err != nil {
154-
return fmt.Errorf("build schema validator: %w", err)
156+
return fmt.Errorf("failed to build schema validator: %w", err)
155157
}
156158

157159
// ── Services ─────────────────────
@@ -217,14 +219,20 @@ func run(ctx context.Context, cfg Config, pool database.Pool, userFiles []string
217219
flowDefinitionSvc,
218220
teamService),
219221
api.NewSecurityHandler(),
222+
oasapi.WithMiddleware(
223+
middleware.AddOperationIdToContext(),
224+
// logging is done at net/http level
225+
),
226+
oasapi.WithMeterProvider(metrics.MeterProvider()),
227+
oasapi.WithTracerProvider(metrics.TracerProvider()),
220228
oasapi.WithErrorHandler(api.OgenErrorHandler))
221229
if err != nil {
222-
return fmt.Errorf("build api server: %w", err)
230+
return fmt.Errorf("failed to build api server: %w", err)
223231
}
224232

225-
mux, err := buildHTTPMux(cfg.Server, oasServer)
233+
mux, err := buildHTTPMux(cfg.Server, idgen.NewULID(), oasServer)
226234
if err != nil {
227-
return err
235+
return fmt.Errorf("failed to build http mux: %w", err)
228236
}
229237

230238
httpServer := &http.Server{
@@ -238,10 +246,11 @@ func run(ctx context.Context, cfg Config, pool database.Pool, userFiles []string
238246

239247
serverErr := make(chan error, 1)
240248
go func() {
241-
log.Printf("server listening on %s", httpServer.Addr)
249+
slog.Info("server listening for requests", slog.String("address", httpServer.Addr))
242250
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
243251
serverErr <- err
244252
}
253+
slog.Debug("stopped listening")
245254
close(serverErr)
246255
}()
247256

@@ -252,12 +261,15 @@ func run(ctx context.Context, cfg Config, pool database.Pool, userFiles []string
252261
if err := httpServer.Shutdown(shutdownCtx); err != nil {
253262
return fmt.Errorf("shutdown server: %w", err)
254263
}
264+
slog.Info("server shut down")
255265
return nil
256266
case err := <-serverErr:
257267
return err
258268
}
259269
}
260270

271+
// ----------------------------- CONFIG --------------------------------------
272+
261273
func loadConfig(configPath string) (Config, error) {
262274
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
263275
v.SetEnvPrefix("NEXTGEN")
@@ -283,6 +295,20 @@ func loadConfig(configPath string) (Config, error) {
283295
v.SetDefault("schema.builtin_public_base", "https://nextgen.com/api/schemas") // todo: temp, review
284296
v.SetDefault("session.default_ttl", domain.SessionAnonymousTTL)
285297
v.SetDefault("session.max_ttl", 720*time.Hour)
298+
v.SetDefault("instrumentation.service_name", "Zitadel")
299+
v.SetDefault("instrumentation.log.level", zlog.LevelInfo)
300+
v.SetDefault("instrumentation.log.streams", []zlog.Stream{
301+
zlog.StreamRuntime,
302+
zlog.StreamReady,
303+
zlog.StreamRequest,
304+
zlog.StreamService,
305+
zlog.StreamStorage,
306+
})
307+
v.SetDefault("instrumentation.log.format", instrumentation.LogFormatText)
308+
v.SetDefault("instrumentation.log.add_source", true)
309+
v.SetDefault("instrumentation.log.errors.report_location", true)
310+
v.SetDefault("instrumentation.log.errors.stack_trace", true)
311+
v.SetDefault("instrumentation.trace.fraction", 1.0)
286312

287313
// AutomaticEnv only resolves nested keys viper already knows about
288314
// (via default, config file, fields of config struct or explicit BindEnv).
@@ -327,7 +353,9 @@ func mustBindEnv(v *viper.Viper, key string) {
327353
}
328354
}
329355

330-
func buildHTTPMux(cfg ServerConfig, apiHandler http.Handler) (*http.ServeMux, error) {
356+
// ----------------------------- HTTP --------------------------------------
357+
358+
func buildHTTPMux(cfg ServerConfig, reqIdGen idgen.Generator, apiHandler http.Handler) (*http.ServeMux, error) {
331359
mux := http.NewServeMux()
332360

333361
if cfg.LoginEnabled {
@@ -354,10 +382,56 @@ func buildHTTPMux(cfg ServerConfig, apiHandler http.Handler) (*http.ServeMux, er
354382
mux.Handle(cfg.ConsolePath+"/", consoleHandler)
355383
}
356384

357-
mux.Handle("/", api.WithRequestHostMiddleware(apiHandler))
385+
mux.Handle("/",
386+
middleware.WithRequestIdentification(reqIdGen,
387+
middleware.WithLogging(
388+
api.WithRequestHostMiddleware(apiHandler),
389+
),
390+
),
391+
)
358392
return mux, nil
359393
}
360394

395+
// ----------------------------- STORAGE --------------------------------------
396+
397+
func startDatabase(ctx context.Context, cfg Config) (database.Pool, error) {
398+
connector, err := buildDatabaseConnector(cfg)
399+
if err != nil {
400+
return nil, err
401+
}
402+
pool, err := connector.Connect(ctx)
403+
if err != nil {
404+
return nil, err
405+
}
406+
err = pool.Migrate(ctx)
407+
if err != nil {
408+
return nil, err
409+
}
410+
return pool, nil
411+
}
412+
413+
func buildDatabaseConnector(cfg Config) (database.Connector, error) {
414+
if len(cfg.Database.Raw) == 0 {
415+
options := embeddedPostgresOptions(cfg.Server.DataDir)
416+
slog.Info("no database dialect configured, starting embedded postgres", slog.String("filePath", filepath.Dir(options.DataPath)))
417+
return embedded.NewConnector(options), nil
418+
}
419+
return cfg.Database.Build()
420+
}
421+
422+
func embeddedPostgresOptions(dataDir string) embedded.Options {
423+
root := filepath.Join(dataDir, "embedded-postgres")
424+
return embedded.Options{
425+
RuntimePath: filepath.Join(root, "runtime"),
426+
DataPath: filepath.Join(root, "data"),
427+
CachePath: filepath.Join(root, "cache"),
428+
LogPath: filepath.Join(root, "postgres.log"),
429+
Logger: os.Stdout,
430+
}
431+
}
432+
433+
// ----------------------------- CRYPTO --------------------------------------
434+
361435
// buildCrypter decodes a hex-encoded crypter key and constructs a
362436
// [crypto.Crypter]. The key must decode to exactly 32 bytes;
363437
// anything else is a configuration error.
@@ -375,3 +449,25 @@ func buildCrypter(hexKey string) (crypto.Crypter, error) {
375449
crypter := op.NewAES256GCMCrypto([32]byte(key), "")
376450
return crypter, nil
377451
}
452+
453+
// ----------------------------- INSTRUMENTATION --------------------------------------
454+
455+
func setUpLogging(cfg instrumentation.LogConfig, otelProvider log.LoggerProvider) {
456+
otelHandler := otelslog.NewHandler(
457+
Name,
458+
otelslog.WithLoggerProvider(otelProvider),
459+
)
460+
461+
stdErrHandler := cfg.Format.ErrorHandler(cfg.SlogHandlerOptions())
462+
handler := zlog.NewHandler(
463+
cfg.Level,
464+
cfg.Streams,
465+
slog.NewMultiHandler(
466+
otelHandler,
467+
stdErrHandler,
468+
),
469+
)
470+
logger := zlog.NewLogger(handler)
471+
logger.Info("structured logger configured", "config_level", cfg.Level, "format", cfg.Format)
472+
slog.SetDefault(logger)
473+
}

0 commit comments

Comments
 (0)