mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 13:52:51 +00:00
# Which Problems Are Solved
In the "new" structured logging, request details were added in different
middlewares, such as request, instance and user IDs. This meant the the
upstream request logging middleware did not have access to metadata that
got added later, resulting in incomplete logs. Furthermore it was not
possible to correlate an API error response to log output.
# How the Problems Are Solved
A mutable request details object is added to the context early on. When
the api authz function run, the instance and user IDs are added to this
object as they become available. Every logline emitted after this
(time-wise) will then all contain these details under the `request` log
group.
<details>
<summary>example output in JSON</summary>
```json
{
"time": "2026-03-13T19:21:20.890428806Z",
"level": "INFO",
"source": {
"function": "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware.LogHandler.func1.1",
"file": "/workspaces/zitadel/internal/api/grpc/server/connect_middleware/log_interceptor.go",
"line": 34
},
"msg": "request served",
"request": {
"id": "d6q67c04vtjmi77cbbpg",
"instance_host": "localhost:8080",
"instance_id": "362349751439458307",
"user_id": "362349751440048131"
},
"TraceID": "a9e0fee3522224f3583bbdcda737f4b4",
"SpanID": "a563056eee36920a",
"stream": "request",
"version": "2026-03-13T19:20:59Z",
"protocol": "connect",
"service": "zitadel.user.v2.UserService",
"http_method": "POST",
"path": "/zitadel.user.v2.UserService/ListUsers",
"code": "code_0",
"duration": 12254350
}
```
</details>
Request IDs are now also returned with a response header or metadata.
Depending on the protocol:
- HTTP calls always return the request ID as header, regardless of
status
- gRPC calls always return the request ID as header, even if there was
an error
- connect RPC calls returns the request ID as header on success, trailer
in case of error. This is because header must be set on the response
object, which is nil in case of error. When there is an error, metadata
can be added which are then sent as trailers.
# Additional Changes
- Use the existing call duration middleware for both request ID and
logging for a consistent request start timestamp in all layers.
- Upgrade sloggcp for some fixes (notably TraceID)
- Modify the NoCache middleware so it uses `SetHeaders` instead of
`SendHeaders`. The latter prevented any other handler from setting
headers, including the new request ID middleware.
# Additional Context
Follow up on demo of:
- https://github.com/zitadel/zitadel/pull/11159
- https://github.com/zitadel/zitadel/pull/11435
- backport to v4
---------
Co-authored-by: Copilot <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Marco A. <[email protected]>
386 lines
13 KiB
Go
386 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"connectrpc.com/grpcreflect"
|
|
"connectrpc.com/otelconnect"
|
|
"github.com/gorilla/mux"
|
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
"github.com/zitadel/logging"
|
|
"go.opentelemetry.io/otel"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/health"
|
|
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
|
|
|
"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"
|
|
"github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware"
|
|
http_util "github.com/zitadel/zitadel/internal/api/http"
|
|
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
|
|
"github.com/zitadel/zitadel/internal/api/ui/login"
|
|
"github.com/zitadel/zitadel/internal/crypto"
|
|
"github.com/zitadel/zitadel/internal/denylist"
|
|
"github.com/zitadel/zitadel/internal/i18n"
|
|
"github.com/zitadel/zitadel/internal/query"
|
|
"github.com/zitadel/zitadel/internal/telemetry/tracing"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
instance_pb "github.com/zitadel/zitadel/pkg/grpc/instance/v2"
|
|
system_pb "github.com/zitadel/zitadel/pkg/grpc/system"
|
|
)
|
|
|
|
var (
|
|
metricTypes = []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode}
|
|
)
|
|
|
|
type API struct {
|
|
port uint16
|
|
externalDomain string
|
|
grpcServer *grpc.Server
|
|
verifier authz.APITokenVerifier
|
|
health healthCheck
|
|
router *mux.Router
|
|
hostHeaders []string
|
|
grpcGateway *server.Gateway
|
|
healthServer *health.Server
|
|
accessInterceptor *http_mw.AccessInterceptor
|
|
queries *query.Queries
|
|
authConfig authz.Config
|
|
systemAuthZ authz.Config
|
|
connectServices map[string][]string
|
|
|
|
targetEncryptionAlgorithm crypto.EncryptionAlgorithm
|
|
translator *i18n.Translator
|
|
connectOTELInterceptor *otelconnect.Interceptor
|
|
actionV2DenyList []denylist.AddressChecker
|
|
}
|
|
|
|
func (a *API) ListGrpcServices() []string {
|
|
serviceInfo := a.grpcServer.GetServiceInfo()
|
|
services := make([]string, len(serviceInfo)+len(a.connectServices))
|
|
i := 0
|
|
for servicename := range serviceInfo {
|
|
services[i] = servicename
|
|
i++
|
|
}
|
|
for prefix := range a.connectServices {
|
|
services[i] = strings.Trim(prefix, "/")
|
|
i++
|
|
}
|
|
sort.Strings(services)
|
|
return services
|
|
}
|
|
|
|
func (a *API) ListGrpcMethods() []string {
|
|
serviceInfo := a.grpcServer.GetServiceInfo()
|
|
methods := make([]string, 0)
|
|
for servicename, service := range serviceInfo {
|
|
for _, method := range service.Methods {
|
|
methods = append(methods, "/"+servicename+"/"+method.Name)
|
|
}
|
|
}
|
|
for service, methodList := range a.connectServices {
|
|
for _, method := range methodList {
|
|
methods = append(methods, service+method)
|
|
}
|
|
}
|
|
sort.Strings(methods)
|
|
return methods
|
|
}
|
|
|
|
type healthCheck interface {
|
|
Health(ctx context.Context) error
|
|
}
|
|
|
|
func New(
|
|
ctx context.Context,
|
|
port uint16,
|
|
router *mux.Router,
|
|
queries *query.Queries,
|
|
verifier authz.APITokenVerifier,
|
|
systemAuthz authz.Config,
|
|
authZ authz.Config,
|
|
tlsConfig *tls.Config,
|
|
externalDomain string,
|
|
hostHeaders []string,
|
|
accessInterceptor *http_mw.AccessInterceptor,
|
|
targetEncryptionAlgorithm crypto.EncryptionAlgorithm,
|
|
translator *i18n.Translator,
|
|
trustRemoteSpans bool,
|
|
deniedIPList []denylist.AddressChecker,
|
|
) (_ *API, err error) {
|
|
api := &API{
|
|
port: port,
|
|
externalDomain: externalDomain,
|
|
verifier: verifier,
|
|
health: queries,
|
|
router: router,
|
|
queries: queries,
|
|
accessInterceptor: accessInterceptor,
|
|
hostHeaders: hostHeaders,
|
|
authConfig: authZ,
|
|
systemAuthZ: systemAuthz,
|
|
connectServices: make(map[string][]string),
|
|
targetEncryptionAlgorithm: targetEncryptionAlgorithm,
|
|
translator: translator,
|
|
actionV2DenyList: deniedIPList,
|
|
}
|
|
|
|
api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService(), targetEncryptionAlgorithm, api.translator, deniedIPList)
|
|
api.grpcGateway, err = server.CreateGateway(ctx, port, hostHeaders, accessInterceptor, tlsConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
otelOpts := []otelconnect.Option{
|
|
otelconnect.WithTracerProvider(otel.GetTracerProvider()),
|
|
otelconnect.WithMeterProvider(otel.GetMeterProvider()),
|
|
otelconnect.WithPropagator(otel.GetTextMapPropagator()),
|
|
otelconnect.WithoutServerPeerAttributes(),
|
|
}
|
|
if trustRemoteSpans {
|
|
otelOpts = append(otelOpts, otelconnect.WithTrustRemote())
|
|
}
|
|
api.connectOTELInterceptor, err = otelconnect.NewInterceptor(otelOpts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
api.registerHealthServer()
|
|
|
|
api.RegisterHandlerOnPrefix("/debug", api.healthHandler())
|
|
api.router.Handle("/", http.RedirectHandler(login.HandlerPrefix, http.StatusFound))
|
|
|
|
return api, nil
|
|
}
|
|
|
|
func (a *API) serverReflection() {
|
|
reflector := grpcreflect.NewStaticReflector(a.ListGrpcServices()...)
|
|
a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1(reflector))
|
|
a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1Alpha(reflector))
|
|
}
|
|
|
|
// RegisterServer registers a grpc service on the grpc server,
|
|
// creates a new grpc gateway and registers it as a separate http handler
|
|
//
|
|
// used for v1 api (system, admin, mgmt, auth)
|
|
func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayPrefix, tlsConfig *tls.Config) error {
|
|
grpcServer.RegisterServer(a.grpcServer)
|
|
handler, prefix, err := server.CreateGatewayWithPrefix(
|
|
ctx,
|
|
grpcServer,
|
|
a.port,
|
|
a.hostHeaders,
|
|
a.accessInterceptor,
|
|
tlsConfig,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.RegisterHandlerOnPrefix(prefix, handler)
|
|
a.verifier.RegisterServer(grpcServer.AppName(), grpcServer.MethodPrefix(), grpcServer.AuthMethods())
|
|
a.healthServer.SetServingStatus(grpcServer.MethodPrefix(), healthpb.HealthCheckResponse_SERVING)
|
|
return nil
|
|
}
|
|
|
|
// RegisterService registers a grpc service on the grpc server,
|
|
// and its gateway on the gateway handler
|
|
//
|
|
// used for >= v2 api (e.g. user, session, ...)
|
|
func (a *API) RegisterService(ctx context.Context, srv server.Server) error {
|
|
switch service := srv.(type) {
|
|
case server.GrpcServer:
|
|
service.RegisterServer(a.grpcServer)
|
|
case server.ConnectServer:
|
|
a.registerConnectServer(service)
|
|
}
|
|
if withGateway, ok := srv.(server.WithGateway); ok {
|
|
err := server.RegisterGateway(ctx, a.grpcGateway, withGateway)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.verifier.RegisterServer(srv.AppName(), srv.MethodPrefix(), srv.AuthMethods())
|
|
a.healthServer.SetServingStatus(srv.MethodPrefix(), healthpb.HealthCheckResponse_SERVING)
|
|
return nil
|
|
}
|
|
|
|
func (a *API) registerConnectServer(service server.ConnectServer) {
|
|
prefix, handler := service.RegisterConnectServer(
|
|
connect_middleware.CallDurationHandler(),
|
|
connect_middleware.RequestDetailsHandler(),
|
|
a.connectOTELInterceptor,
|
|
connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...),
|
|
connect_middleware.LogHandler(grpc_api.Probes...),
|
|
connect_middleware.NoCacheInterceptor(),
|
|
connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, a.translator, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName, instance_pb.InstanceService_ServiceDesc.ServiceName),
|
|
connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()),
|
|
connect_middleware.ErrorHandler(),
|
|
connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName),
|
|
connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig),
|
|
connect_middleware.TranslationHandler(),
|
|
connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName),
|
|
connect_middleware.ExecutionHandler(a.targetEncryptionAlgorithm, a.queries.GetActiveSigningWebKey, a.actionV2DenyList),
|
|
connect_middleware.ValidationHandler(),
|
|
connect_middleware.ServiceHandler(),
|
|
connect_middleware.ActivityInterceptor(),
|
|
)
|
|
methods := service.FileDescriptor().Services().Get(0).Methods()
|
|
methodNames := make([]string, methods.Len())
|
|
for i := 0; i < methods.Len(); i++ {
|
|
methodNames[i] = string(methods.Get(i).Name())
|
|
}
|
|
a.connectServices[prefix] = methodNames
|
|
a.RegisterHandlerPrefixes(http_mw.CORSInterceptor(handler), prefix)
|
|
}
|
|
|
|
// HandleFunc allows registering a [http.HandlerFunc] on an exact
|
|
// path, instead of prefix like RegisterHandlerOnPrefix.
|
|
func (a *API) HandleFunc(path string, f http.HandlerFunc) {
|
|
a.router.HandleFunc(path, f)
|
|
}
|
|
|
|
// RegisterHandlerOnPrefix registers an http handler on a path prefix.
|
|
// The prefix will not be passed to the actual handler.
|
|
func (a *API) RegisterHandlerOnPrefix(prefix string, handler http.Handler) {
|
|
prefix = strings.TrimSuffix(prefix, "/")
|
|
subRouter := a.router.PathPrefix(prefix).Name(prefix).Subrouter()
|
|
subRouter.PathPrefix("").Handler(http.StripPrefix(prefix, handler))
|
|
}
|
|
|
|
// RegisterHandlerPrefixes registers an http handler on multiple path prefixes.
|
|
// The prefix will remain when calling the actual handler.
|
|
func (a *API) RegisterHandlerPrefixes(handler http.Handler, prefixes ...string) {
|
|
for _, prefix := range prefixes {
|
|
prefix = strings.TrimSuffix(prefix, "/")
|
|
subRouter := a.router.PathPrefix(prefix).Name(prefix).Subrouter()
|
|
subRouter.PathPrefix("").Handler(handler)
|
|
}
|
|
}
|
|
|
|
func (a *API) registerHealthServer() {
|
|
healthServer := health.NewServer()
|
|
healthpb.RegisterHealthServer(a.grpcServer, healthServer)
|
|
a.healthServer = healthServer
|
|
}
|
|
|
|
func (a *API) RouteGRPC() {
|
|
// since all services are now registered, we can build the grpc server reflection and register the handler
|
|
a.serverReflection()
|
|
|
|
http2Route := a.router.
|
|
MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool {
|
|
return r.ProtoMajor == 2
|
|
}).
|
|
Subrouter().
|
|
Name("grpc")
|
|
http2Route.
|
|
Methods(http.MethodPost).
|
|
HeadersRegexp(http_util.ContentType, `^application\/grpc(\+proto|\+json)?$`).
|
|
Handler(a.grpcServer)
|
|
|
|
a.routeGRPCWeb()
|
|
a.router.NewRoute().
|
|
Handler(a.grpcGateway.Handler()).
|
|
Name("grpc-gateway")
|
|
}
|
|
|
|
func (a *API) routeGRPCWeb() {
|
|
grpcWebServer := grpcweb.WrapServer(a.grpcServer,
|
|
grpcweb.WithAllowedRequestHeaders(
|
|
[]string{
|
|
http_util.Origin,
|
|
http_util.ContentType,
|
|
http_util.Accept,
|
|
http_util.AcceptLanguage,
|
|
http_util.Authorization,
|
|
http_util.ZitadelOrgID,
|
|
http_util.XUserAgent,
|
|
http_util.XGrpcWeb,
|
|
},
|
|
),
|
|
grpcweb.WithOriginFunc(func(_ string) bool {
|
|
return true
|
|
}),
|
|
)
|
|
a.router.Use(http_mw.RobotsTagHandler)
|
|
a.router.NewRoute().
|
|
Methods(http.MethodPost, http.MethodOptions).
|
|
MatcherFunc(
|
|
func(r *http.Request, _ *mux.RouteMatch) bool {
|
|
return grpcWebServer.IsGrpcWebRequest(r) || grpcWebServer.IsAcceptableGrpcCorsRequest(r)
|
|
}).
|
|
Handler(grpcWebServer).
|
|
Name("grpc-web")
|
|
}
|
|
|
|
func (a *API) healthHandler() http.Handler {
|
|
checks := []ValidationFunction{
|
|
func(ctx context.Context) error {
|
|
if err := a.health.Health(ctx); err != nil {
|
|
return zerrors.ThrowInternal(err, "API-F24h2", "DB CONNECTION ERROR")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
handler := http.NewServeMux()
|
|
handler.HandleFunc("/healthz", handleHealth)
|
|
handler.HandleFunc("/ready", handleReadiness(checks))
|
|
handler.HandleFunc("/validate", handleValidate(checks))
|
|
handler.Handle("/metrics", metricsExporter())
|
|
|
|
return handler
|
|
}
|
|
|
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
_, err := w.Write([]byte("ok"))
|
|
logging.WithFields("traceID", tracing.TraceIDFromCtx(r.Context())).OnError(err).Error("error writing ok for health")
|
|
}
|
|
|
|
func handleReadiness(checks []ValidationFunction) func(w http.ResponseWriter, r *http.Request) {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
errs := validate(r.Context(), checks)
|
|
if len(errs) == 0 {
|
|
http_util.MarshalJSON(w, "ok", nil, http.StatusOK)
|
|
return
|
|
}
|
|
http_util.MarshalJSON(w, nil, errs[0], http.StatusPreconditionFailed)
|
|
}
|
|
}
|
|
|
|
func handleValidate(checks []ValidationFunction) func(w http.ResponseWriter, r *http.Request) {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
errs := validate(r.Context(), checks)
|
|
if len(errs) == 0 {
|
|
http_util.MarshalJSON(w, "ok", nil, http.StatusOK)
|
|
return
|
|
}
|
|
http_util.MarshalJSON(w, errs, nil, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
type ValidationFunction func(ctx context.Context) error
|
|
|
|
func validate(ctx context.Context, validations []ValidationFunction) []error {
|
|
errs := make([]error, 0)
|
|
for _, validation := range validations {
|
|
if err := validation(ctx); err != nil {
|
|
logging.WithFields("traceID", tracing.TraceIDFromCtx(ctx)).WithError(err).Error("validation failed")
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func metricsExporter() http.Handler {
|
|
exporter := metrics.GetExporter()
|
|
if exporter == nil {
|
|
return http.NotFoundHandler()
|
|
}
|
|
return exporter
|
|
}
|