Files
zitadel/internal/api/ui/console/console.go
997aa607c4 feat(telemetry): unify request details (#11509)
# 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 <198982749+Copilot@users.noreply.github.com>
Co-authored-by: muhlemmer <5411563+muhlemmer@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Marco A. <marco@zitadel.com>
2026-03-18 14:07:33 +01:00

218 lines
6.7 KiB
Go

package console
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/cmd/build"
"github.com/zitadel/zitadel/internal/api/authz"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/http/middleware"
console_path "github.com/zitadel/zitadel/internal/api/ui/console/path"
)
type Config struct {
ShortCache middleware.CacheConfig
LongCache middleware.CacheConfig
InstanceManagementURL string
PostHog struct {
Token string
URL string
}
}
type spaHandler struct {
fileSystem http.FileSystem
}
var (
//go:embed static
static embed.FS
)
const (
envRequestPath = "/assets/environment.json"
// https://posthog.com/docs/advanced/content-security-policy
posthogCSPHost = "https://*.i.posthog.com"
)
var (
shortCacheFiles = []string{
"/",
"/index.html",
"/manifest.webmanifest",
"/ngsw.json",
"/ngsw-worker.js",
"/safety-worker.js",
"/worker-basic.min.js",
}
)
func LoginHintLink(origin, username string) string {
return origin + console_path.HandlerPrefix + "?login_hint=" + username
}
func (i *spaHandler) Open(name string) (http.File, error) {
ret, err := i.fileSystem.Open(name)
if !os.IsNotExist(err) || path.Ext(name) != "" {
return ret, err
}
f, err := i.fileSystem.Open("/index.html")
if err != nil {
return nil, err
}
return &file{File: f}, nil
}
// file wraps the http.File and fs.FileInfo interfaces
// to return the build.Date() as ModTime() of the file
type file struct {
http.File
fs.FileInfo
}
func (f *file) ModTime() time.Time {
return build.Date()
}
func (f *file) Stat() (_ fs.FileInfo, err error) {
f.FileInfo, err = f.File.Stat()
if err != nil {
return nil, err
}
return f, nil
}
func Start(config Config, externalSecure bool, issuer op.IssuerFromRequest, callDurationInterceptor, instanceHandler func(http.Handler) http.Handler, limitingAccessInterceptor *middleware.AccessInterceptor, customerPortal string) (http.Handler, error) {
fSys, err := fs.Sub(static, "static")
if err != nil {
return nil, err
}
cache := assetsCacheInterceptorIgnoreManifest(
config.ShortCache.MaxAge,
config.ShortCache.SharedMaxAge,
config.LongCache.MaxAge,
config.LongCache.SharedMaxAge,
)
security := middleware.SecurityHeaders(csp(config.PostHog.URL), nil)
handler := mux.NewRouter()
handler.Use(security, limitingAccessInterceptor.WithoutLimiting().Handle)
env := handler.NewRoute().Path(envRequestPath).Subrouter()
env.Use(
callDurationInterceptor,
middleware.RequestDetailsHandler(),
middleware.TraceHandler(),
middleware.LogHandler("console"),
instanceHandler,
)
env.HandleFunc("", func(w http.ResponseWriter, r *http.Request) {
url := http_util.BuildOrigin(r.Host, externalSecure)
ctx := r.Context()
instance := authz.GetInstance(ctx)
instanceMgmtURL, err := templateInstanceManagementURL(config.InstanceManagementURL, instance)
if err != nil {
http.Error(w, fmt.Sprintf("unable to template instance management url for the management console: %v", err), http.StatusInternalServerError)
return
}
limited := limitingAccessInterceptor.Limit(w, r)
environmentJSON, err := createEnvironmentJSON(url, issuer(r), instance.ManagementConsoleClientID(), customerPortal, instanceMgmtURL, config.PostHog.URL, config.PostHog.Token, limited)
if err != nil {
http.Error(w, fmt.Sprintf("unable to marshal env for the management console: %v", err), http.StatusInternalServerError)
return
}
_, err = w.Write(environmentJSON)
logging.OnError(err).Error("error serving environment.json")
})
handler.SkipClean(true).PathPrefix("").Handler(cache(http.FileServer(&spaHandler{http.FS(fSys)})))
return handler, nil
}
func templateInstanceManagementURL(templateableCookieValue string, instance authz.Instance) (string, error) {
cookieValueTemplate, err := template.New("cookievalue").Parse(templateableCookieValue)
if err != nil {
return templateableCookieValue, err
}
cookieValue := new(bytes.Buffer)
if err = cookieValueTemplate.Execute(cookieValue, instance); err != nil {
return templateableCookieValue, err
}
return cookieValue.String(), nil
}
func csp(posthogURL string) *middleware.CSP {
csp := middleware.DefaultSCP
csp.StyleSrc = csp.StyleSrc.AddInline()
csp.ScriptSrc = csp.ScriptSrc.AddEval()
csp.ConnectSrc = csp.ConnectSrc.AddOwnHost()
csp.ImgSrc = csp.ImgSrc.AddOwnHost().AddScheme("blob")
if posthogURL != "" {
// https://posthog.com/docs/advanced/content-security-policy#enabling-the-toolbar
csp.ScriptSrc = csp.ScriptSrc.AddHost(posthogCSPHost)
csp.ConnectSrc = csp.ConnectSrc.AddHost(posthogCSPHost)
csp.ImgSrc = csp.ImgSrc.AddHost(posthogCSPHost)
csp.StyleSrc = csp.StyleSrc.AddHost(posthogCSPHost)
csp.FontSrc = csp.FontSrc.AddHost(posthogCSPHost)
csp.MediaSrc = middleware.CSPSourceOpts().AddHost(posthogCSPHost)
}
return &csp
}
func createEnvironmentJSON(api, issuer, clientID, customerPortal, instanceMgmtUrl, postHogURL, postHogToken string, exhausted bool) ([]byte, error) {
environment := struct {
API string `json:"api,omitempty"`
Issuer string `json:"issuer,omitempty"`
ClientID string `json:"clientid,omitempty"`
CustomerPortal string `json:"customer_portal,omitempty"`
InstanceManagementURL string `json:"instance_management_url,omitempty"`
PostHogURL string `json:"posthog_url,omitempty"`
PostHogToken string `json:"posthog_token,omitempty"`
Exhausted bool `json:"exhausted,omitempty"`
}{
API: api,
Issuer: issuer,
ClientID: clientID,
CustomerPortal: customerPortal,
InstanceManagementURL: instanceMgmtUrl,
PostHogURL: postHogURL,
PostHogToken: postHogToken,
Exhausted: exhausted,
}
return json.Marshal(environment)
}
func assetsCacheInterceptorIgnoreManifest(shortMaxAge, shortSharedMaxAge, longMaxAge, longSharedMaxAge time.Duration) func(http.Handler) http.Handler {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, file := range shortCacheFiles {
if r.URL.Path == file || isIndexOrSubPath(r.URL.Path) {
middleware.AssetsCacheInterceptor(shortMaxAge, shortSharedMaxAge).Handler(handler).ServeHTTP(w, r)
return
}
}
middleware.AssetsCacheInterceptor(longMaxAge, longSharedMaxAge).Handler(handler).ServeHTTP(w, r)
})
}
}
func isIndexOrSubPath(path string) bool {
//files will have an extension
return !strings.Contains(path, ".")
}