feat(telemetry): improved instrumentation for observability (#11159)

# Which Problems Are Solved

Zitadel did not provide easy correlation between errors, logs, traces
and metrics. The configuration for those instrumentations was also not
consistent, with some supporting different exporters then others.
Implementation and parsing of config was also spaghettified over
multiple packages, with awkward parsing and inconsistent naming of
options.

# How the Problems Are Solved

All telemetry is now merged under the name "instrumentation". Why?
1. We thought it was a good idea in the past to call the milestone
exporter `Telemtry` in the runtime config. Calling this `TelemetryV2`
looks weird.
2. Not everything is a meter and not everything is sent (tele...). 
3. It's also
[defined](https://opentelemetry.io/docs/concepts/instrumentation/) as
such by the OTEL documentation.

## New features

- Adds structured, context based logging with trace-ID awareness
- Static log fields are added to the context, such as service and
request path
- Static log fields are injected in each logline emitted by the
application
- Structured logs can also be send to an otel exporter
- Structured logs can be printed to StdErr in text and JSON format
- Error sinks make sure every error is logged at the correct level:
- Warnings for client side errors (HTTP 400 range, Invalid request etc)
  - Error for server side errors (Internal server errors)
- Metrics can now also be send to a OTEL collector. (previously they
could only be scraped from `/debug/metrics` with prometheus)

## Exporters

This change adds all the exporters supported by OTEL upstream and some
google specific exporters for our cloud deployment.

- StdOut / StdErr: all instrumentations
- OTEL gRPC / HTTP: all instrumentations
- Google: all instrumentations except logging
- Prometheus (pull-based): only metrics

The exception is profiling, which only supports the google exporting due
to lack of support by OTEL upstream.

## Configuration and structure 

- All instrumentation is moved into the new `backend/v3/instrumentation`
package. It reuses configuration types, so both code and runtime
configuration are easier to understand.
- The `internal/telemetry` packages are removed.
- Instrumentation is started with a single function and a proper
shutdown function is now provided.
- Legacy configuration is still parsed from the runtime config, as long
as the new configuration is disabled. This allows backporting this
feature to v4 without breaking existing configurations.

# Additional Changes

- Devcontainer: set `$PATH` variable so installed go binaries can be run
individually, without NX.
- NX: install GCI tool to fix imports

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/8408
- Closes https://github.com/zitadel/zitadel/issues/6664
- Backport to v4
This commit is contained in:
Tim Möhlmann
2026-01-12 05:51:39 +00:00
committed by GitHub
parent d7a9e9d48b
commit eb22b58756
160 changed files with 4320 additions and 2135 deletions
+61 -40
View File
@@ -1,10 +1,13 @@
package gerrors
import (
"context"
"errors"
"log/slog"
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgconn"
slogctx "github.com/veqryn/slog-context"
"github.com/zitadel/logging"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -16,16 +19,15 @@ import (
"github.com/zitadel/zitadel/pkg/grpc/message"
)
func ZITADELToGRPCError(err error) error {
func ZITADELToGRPCError(ctx context.Context, err error) error {
if err == nil {
return nil
}
code, key, id, ok := ExtractZITADELError(err)
if !ok {
return status.Convert(err).Err()
}
code, key, id, lvl := extractError(err)
msg := key
msg += " (" + id + ")"
slogctx.FromCtx(ctx).Log(ctx, lvl, msg, "err", err, "code", code)
errorInfo := getErrorInfo(id, key, err)
@@ -38,20 +40,21 @@ func ZITADELToGRPCError(err error) error {
return s.Err()
}
func ZITADELToConnectError(err error) error {
func ZITADELToConnectError(ctx context.Context, err error) error {
if err == nil {
return nil
}
connectError := new(connect.Error)
if errors.As(err, &connectError) {
// Connect error may be returned by other middlewares,
// so we assume it's a client error and log as warning.
slogctx.FromCtx(ctx).WarnContext(ctx, connectError.Message(), "err", connectError.Unwrap(), "code", connectError.Code())
return err
}
code, key, id, ok := ExtractZITADELError(err)
if !ok {
return status.Convert(err).Err()
}
code, key, id, lvl := extractError(err)
msg := key
msg += " (" + id + ")"
slogctx.FromCtx(ctx).Log(ctx, lvl, msg, "err", err)
errorInfo := getErrorInfo(id, key, err)
@@ -62,44 +65,62 @@ func ZITADELToConnectError(err error) error {
return cErr
}
func ExtractZITADELError(err error) (c codes.Code, msg, id string, ok bool) {
func ExtractZITADELError(err error) (code codes.Code, msg, id string) {
if err == nil {
return codes.OK, "", "", false
return codes.OK, "", ""
}
code, msg, id, _ = extractError(err)
return code, msg, id
}
func extractError(err error) (c codes.Code, msg, id string, lvl slog.Level) {
connErr := new(pgconn.ConnectError)
if ok := errors.As(err, &connErr); ok {
return codes.Internal, "db connection error", "", true
return codes.Internal, "db connection error", "", slog.LevelError
}
zitadelErr := new(zerrors.ZitadelError)
if ok := errors.As(err, &zitadelErr); !ok {
return codes.Unknown, err.Error(), "", false
zitadelErr, ok := zerrors.AsZitadelError(err)
if !ok {
return codes.Unknown, err.Error(), "", slog.LevelError
}
switch {
case zerrors.IsErrorAlreadyExists(err):
return codes.AlreadyExists, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsDeadlineExceeded(err):
return codes.DeadlineExceeded, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsInternal(err):
return codes.Internal, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsErrorInvalidArgument(err):
return codes.InvalidArgument, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsNotFound(err):
return codes.NotFound, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsPermissionDenied(err):
return codes.PermissionDenied, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsPreconditionFailed(err):
return codes.FailedPrecondition, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsUnauthenticated(err):
return codes.Unauthenticated, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsUnavailable(err):
return codes.Unavailable, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsUnimplemented(err):
return codes.Unimplemented, zitadelErr.GetMessage(), zitadelErr.GetID(), true
case zerrors.IsResourceExhausted(err):
return codes.ResourceExhausted, zitadelErr.GetMessage(), zitadelErr.GetID(), true
msg, id = zitadelErr.GetMessage(), zitadelErr.GetID()
switch zitadelErr.Kind {
case zerrors.KindAlreadyExists:
c, lvl = codes.AlreadyExists, slog.LevelWarn
case zerrors.KindDeadlineExceeded:
c, lvl = codes.DeadlineExceeded, slog.LevelError
case zerrors.KindInternal:
c, lvl = codes.Internal, slog.LevelError
case zerrors.KindInvalidArgument:
c, lvl = codes.InvalidArgument, slog.LevelWarn
case zerrors.KindNotFound:
c, lvl = codes.NotFound, slog.LevelWarn
case zerrors.KindPermissionDenied:
c, lvl = codes.PermissionDenied, slog.LevelWarn
case zerrors.KindPreconditionFailed:
c, lvl = codes.FailedPrecondition, slog.LevelWarn
case zerrors.KindUnauthenticated:
c, lvl = codes.Unauthenticated, slog.LevelWarn
case zerrors.KindUnavailable:
c, lvl = codes.Unavailable, slog.LevelError
case zerrors.KindUnimplemented:
c, lvl = codes.Unimplemented, slog.LevelInfo
case zerrors.KindResourceExhausted:
c, lvl = codes.ResourceExhausted, slog.LevelError
case zerrors.KindDataLoss:
c, lvl = codes.DataLoss, slog.LevelError
case zerrors.KindCanceled:
c, lvl = codes.Canceled, slog.LevelWarn
case zerrors.KindAborted:
c, lvl = codes.Aborted, slog.LevelError
case zerrors.KindOutOfRange:
c, lvl = codes.OutOfRange, slog.LevelWarn
case zerrors.KindUnknown:
c, lvl = codes.Unknown, slog.LevelError
default:
return codes.Unknown, err.Error(), "", false
c, lvl = codes.Unknown, slog.LevelError
}
return c, msg, id, lvl
}
func getErrorInfo(id, key string, err error) protoadapt.MessageV1 {
@@ -3,6 +3,7 @@ package gerrors
import (
"errors"
"fmt"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
@@ -41,7 +42,7 @@ func TestCaosToGRPCError(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ZITADELToGRPCError(tt.args.err); (err != nil) != tt.wantErr {
if err := ZITADELToGRPCError(t.Context(), tt.args.err); (err != nil) != tt.wantErr {
t.Errorf("ZITADELToGRPCError() error = %v, wantErr %v", err, tt.wantErr)
}
})
@@ -96,7 +97,7 @@ func Test_getErrorInfo(t *testing.T) {
}
}
func Test_Extract(t *testing.T) {
func Test_extractError(t *testing.T) {
type args struct {
err error
}
@@ -106,7 +107,7 @@ func Test_Extract(t *testing.T) {
wantC codes.Code
wantMsg string
wantID string
wantOk bool
wantLvl slog.Level
}{
{
"already exists",
@@ -114,7 +115,7 @@ func Test_Extract(t *testing.T) {
codes.AlreadyExists,
"already exists",
"id",
true,
slog.LevelWarn,
},
{
"deadline exceeded",
@@ -122,7 +123,7 @@ func Test_Extract(t *testing.T) {
codes.DeadlineExceeded,
"deadline exceeded",
"id",
true,
slog.LevelError,
},
{
"internal error",
@@ -130,7 +131,7 @@ func Test_Extract(t *testing.T) {
codes.Internal,
"internal error",
"id",
true,
slog.LevelError,
},
{
"invalid argument",
@@ -138,7 +139,7 @@ func Test_Extract(t *testing.T) {
codes.InvalidArgument,
"invalid argument",
"id",
true,
slog.LevelWarn,
},
{
"not found",
@@ -146,7 +147,7 @@ func Test_Extract(t *testing.T) {
codes.NotFound,
"not found",
"id",
true,
slog.LevelWarn,
},
{
"permission denied",
@@ -154,7 +155,7 @@ func Test_Extract(t *testing.T) {
codes.PermissionDenied,
"permission denied",
"id",
true,
slog.LevelWarn,
},
{
"precondition failed",
@@ -162,7 +163,7 @@ func Test_Extract(t *testing.T) {
codes.FailedPrecondition,
"precondition failed",
"id",
true,
slog.LevelWarn,
},
{
"unauthenticated",
@@ -170,7 +171,7 @@ func Test_Extract(t *testing.T) {
codes.Unauthenticated,
"unauthenticated",
"id",
true,
slog.LevelWarn,
},
{
"unavailable",
@@ -178,7 +179,7 @@ func Test_Extract(t *testing.T) {
codes.Unavailable,
"unavailable",
"id",
true,
slog.LevelError,
},
{
"unimplemented",
@@ -186,7 +187,7 @@ func Test_Extract(t *testing.T) {
codes.Unimplemented,
"unimplemented",
"id",
true,
slog.LevelInfo,
},
{
"exhausted",
@@ -194,7 +195,7 @@ func Test_Extract(t *testing.T) {
codes.ResourceExhausted,
"exhausted",
"id",
true,
slog.LevelError,
},
{
"unknown",
@@ -202,12 +203,12 @@ func Test_Extract(t *testing.T) {
codes.Unknown,
"unknown",
"",
false,
slog.LevelError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotC, gotMsg, gotID, gotOk := ExtractZITADELError(tt.args.err)
gotC, gotMsg, gotID := ExtractZITADELError(tt.args.err)
if gotC != tt.wantC {
t.Errorf("extract() gotC = %v, want %v", gotC, tt.wantC)
}
@@ -217,9 +218,6 @@ func Test_Extract(t *testing.T) {
if gotID != tt.wantID {
t.Errorf("extract() gotID = %v, want %v", gotID, tt.wantID)
}
if gotOk != tt.wantOk {
t.Errorf("extract() gotOk = %v, want %v", gotOk, tt.wantOk)
}
})
}
}
@@ -19,7 +19,7 @@ func ActivityInterceptor() connect.UnaryInterceptorFunc {
ctx = activityInfoFromGateway(ctx, req.Header()).SetMethod(req.Spec().Procedure).IntoContext(ctx)
resp, err := handler(ctx, req)
if isResourceAPI(req.Spec().Procedure) {
code, _, _, _ := gerrors.ExtractZITADELError(err)
code, _, _ := gerrors.ExtractZITADELError(err)
ctx = ainfo.ActivityInfoFromContext(ctx).SetGRPCStatus(code).IntoContext(ctx)
activity.TriggerGRPCWithContext(ctx, activity.ResourceAPI)
}
@@ -19,5 +19,5 @@ func ErrorHandler() connect.UnaryInterceptorFunc {
func toConnectError(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) {
resp, err := handler(ctx, req)
return resp, gerrors.ZITADELToConnectError(err) // TODO !
return resp, gerrors.ZITADELToConnectError(ctx, err)
}
@@ -77,8 +77,8 @@ func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connec
func addInstanceByDomain(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, domain string) (connect.AnyResponse, error) {
instance, err := verifier.InstanceByHost(ctx, domain, "")
if err != nil {
notFoundErr := new(zerrors.NotFoundError)
if errors.As(err, &notFoundErr) {
notFoundErr := new(zerrors.ZitadelError)
if errors.As(err, &notFoundErr) && notFoundErr.Kind == zerrors.KindNotFound {
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
}
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using domain %s: %w", domain, notFoundErr))
@@ -0,0 +1,13 @@
package connect_middleware
import (
"connectrpc.com/connect"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
)
func LogHandler(ignoredPrefix ...string) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return logging.NewConnectInterceptor(next, ignoredPrefix...)
}
}
@@ -10,8 +10,8 @@ import (
"go.opentelemetry.io/otel/attribute"
"google.golang.org/grpc/codes"
"github.com/zitadel/zitadel/backend/v3/instrumentation/metrics"
_ "github.com/zitadel/zitadel/internal/statik"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
const (
+3 -2
View File
@@ -20,10 +20,10 @@ import (
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"github.com/zitadel/zitadel/backend/v3/instrumentation/metrics"
client_middleware "github.com/zitadel/zitadel/internal/api/grpc/client/middleware"
http_utils "github.com/zitadel/zitadel/internal/api/http"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
const (
@@ -209,7 +209,7 @@ func addInterceptors(
handler = http_mw.CallDurationHandler(handler)
handler = http_mw.CORSInterceptor(handler)
handler = http_mw.RobotsTagHandler(handler)
handler = http_mw.DefaultTelemetryHandler(handler)
handler = http_mw.DefaultTraceHandler(handler)
handler = http_mw.ActivityHandler(handler)
// For some non-obvious reason, the exhaustedCookieInterceptor sends the SetCookie header
// only if it follows the http_mw.DefaultTelemetryHandler
@@ -218,6 +218,7 @@ func addInterceptors(
metrics.MetricTypeTotalCount,
metrics.MetricTypeStatusCode,
}, http_utils.Probes...)(handler)
handler = http_mw.LogHandler("grpc_gateway", http_utils.Probes...)(handler)
return handler
}
@@ -18,7 +18,7 @@ func ActivityInterceptor() grpc.UnaryServerInterceptor {
ctx = activityInfoFromGateway(ctx).SetMethod(info.FullMethod).IntoContext(ctx)
resp, err := handler(ctx, req)
if isResourceAPI(info.FullMethod) {
code, _, _, _ := gerrors.ExtractZITADELError(err)
code, _, _ := gerrors.ExtractZITADELError(err)
ctx = ainfo.ActivityInfoFromContext(ctx).SetGRPCStatus(code).IntoContext(ctx)
activity.TriggerGRPCWithContext(ctx, activity.ResourceAPI)
}
@@ -17,5 +17,5 @@ func ErrorHandler() grpc.UnaryServerInterceptor {
func toGRPCError(ctx context.Context, req interface{}, handler grpc.UnaryHandler) (interface{}, error) {
resp, err := handler(ctx, req)
return resp, gerrors.ZITADELToGRPCError(err)
return resp, gerrors.ZITADELToGRPCError(ctx, err)
}
@@ -77,8 +77,8 @@ func addInstanceByID(ctx context.Context, req interface{}, handler grpc.UnaryHan
func addInstanceByDomain(ctx context.Context, req interface{}, handler grpc.UnaryHandler, verifier authz.InstanceVerifier, translator *i18n.Translator, domain string) (interface{}, error) {
instance, err := verifier.InstanceByHost(ctx, domain, "")
if err != nil {
notFoundErr := new(zerrors.NotFoundError)
if errors.As(err, &notFoundErr) {
notFoundErr := new(zerrors.ZitadelError)
if errors.As(err, &notFoundErr) && notFoundErr.Kind == zerrors.KindNotFound {
notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil)
}
return nil, status.Error(codes.NotFound, fmt.Errorf("unable to set instance using domain %s: %w", domain, notFoundErr).Error())
@@ -0,0 +1,11 @@
package middleware
import (
"google.golang.org/grpc"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
)
func LogHandler(ignoredMethodSuffixes ...string) grpc.UnaryServerInterceptor {
return logging.NewGrpcInterceptor(ignoredMethodSuffixes...)
}
@@ -9,8 +9,8 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/status"
"github.com/zitadel/zitadel/backend/v3/instrumentation/metrics"
_ "github.com/zitadel/zitadel/internal/statik"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
)
const (
+2 -1
View File
@@ -11,6 +11,7 @@ import (
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/zitadel/zitadel/backend/v3/instrumentation/metrics"
"github.com/zitadel/zitadel/internal/api/authz"
grpc_api "github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"
@@ -19,7 +20,6 @@ import (
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/metrics"
system_pb "github.com/zitadel/zitadel/pkg/grpc/system"
)
@@ -71,6 +71,7 @@ func CreateServer(
grpc_middleware.ChainUnaryServer(
middleware.CallDurationHandler(),
middleware.MetricsHandler(metricTypes, grpc_api.Probes...),
middleware.LogHandler(grpc_api.Probes...),
middleware.NoCacheInterceptor(),
middleware.InstanceInterceptor(queries, externalDomain, translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName),
middleware.AccessStorageInterceptor(accessSvc),
@@ -12,13 +12,13 @@ import (
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/integration/sink"
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestServer_StartIdentityProviderIntent(t *testing.T) {
@@ -631,7 +631,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
DisplayName: gu.Ptr("displayname"),
},
Email: &user.SetHumanEmail{
Email: "[email protected]",
Email: "[email protected]",
Verification: &user.SetHumanEmail_SendCode{SendCode: &user.SendEmailVerificationCode{}},
},
},
@@ -1037,4 +1037,4 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) {
assert.EqualExportedValues(t, tt.want, got)
})
}
}
}