Files
zitadel/backend/v3/instrumentation/tracing.go
T
Tim MöhlmannandGitHub eb22b58756 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
2026-01-12 05:51:39 +00:00

270 lines
8.0 KiB
Go

package instrumentation
import (
"context"
"fmt"
"net/http"
"os"
"slices"
google_trace "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/resource"
sdk_trace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"github.com/zitadel/zitadel/internal/api/grpc/gerrors"
)
type TraceConfig struct {
Fraction float64
Exporter ExporterConfig
}
// TODO: remove for v5 release
type LegacyTraceConfig struct {
Type string
Fraction float64
Endpoint string
ProjectID string
}
// SetLegacyConfig sets the fields of the TraceConfig based on the provided LegacyTraceConfig.
// If the Type field is already set to a value other than [ExporterTypeNone], the legacy config is ignored.
func (c *TraceConfig) SetLegacyConfig(lc *LegacyTraceConfig) {
typ := c.Exporter.Type
if lc == nil || !typ.isNone() {
return
}
typeMap := map[string]ExporterType{
"otel": ExporterTypeGRPC,
"google": ExporterTypeGoogle,
"log": ExporterTypeStdOut,
}
c.Fraction = lc.Fraction
c.Exporter.Type = typeMap[lc.Type]
c.Exporter.Endpoint = lc.Endpoint
c.Exporter.GoogleProjectID = lc.ProjectID
}
type Tracer struct {
trace.Tracer
}
func (t *Tracer) NewServerInterceptorSpan(ctx context.Context, name string) (context.Context, *Span) {
return t.newSpanFromName(ctx, name, trace.WithSpanKind(trace.SpanKindServer))
}
func (t *Tracer) NewServerSpan(ctx context.Context, caller string) (context.Context, *Span) {
return t.newSpan(ctx, caller, trace.WithSpanKind(trace.SpanKindServer))
}
func (t *Tracer) NewClientInterceptorSpan(ctx context.Context, name string) (context.Context, *Span) {
return t.newSpanFromName(ctx, name, trace.WithSpanKind(trace.SpanKindClient))
}
func (t *Tracer) NewClientSpan(ctx context.Context, caller string) (context.Context, *Span) {
return t.newSpan(ctx, caller, trace.WithSpanKind(trace.SpanKindClient))
}
func (t *Tracer) NewSpan(ctx context.Context, caller string) (context.Context, *Span) {
return t.newSpan(ctx, caller)
}
func (t *Tracer) newSpan(ctx context.Context, caller string, options ...trace.SpanStartOption) (context.Context, *Span) {
return t.newSpanFromName(ctx, caller, options...)
}
func (t *Tracer) newSpanFromName(ctx context.Context, name string, options ...trace.SpanStartOption) (context.Context, *Span) {
ctx, span := t.Start(ctx, name, options...)
return ctx, CreateSpan(span)
}
func (t *Tracer) NewSpanHTTP(r *http.Request, caller string) (*http.Request, *Span) {
ctx, span := t.NewSpan(r.Context(), caller)
r = r.WithContext(ctx)
return r, span
}
type Span struct {
span trace.Span
opts []trace.SpanEndOption
}
func CreateSpan(span trace.Span) *Span {
return &Span{span: span, opts: []trace.SpanEndOption{}}
}
func (s *Span) End() {
if s.span == nil {
return
}
s.span.End(s.opts...)
}
func (s *Span) EndWithError(err error) {
s.SetStatusByError(err)
s.End()
}
func (s *Span) SetStatusByError(err error) {
if s.span == nil {
return
}
if err != nil {
s.span.RecordError(err)
}
code, msg, id := gerrors.ExtractZITADELError(err)
s.span.SetAttributes(attribute.Int("grpc_code", int(code)), attribute.String("grpc_msg", msg), attribute.String("error_id", id))
}
func newTracerProvider(ctx context.Context, cfg TraceConfig, resource *resource.Resource) (_ *sdk_trace.TracerProvider, err error) {
var exporter sdk_trace.SpanExporter
switch cfg.Exporter.Type {
case ExporterTypeUnspecified, ExporterTypeNone:
// no exporter
case ExporterTypeStdOut, ExporterTypeStdErr:
exporter, err = traceStdOutExporter(cfg.Exporter)
case ExporterTypeGRPC:
exporter, err = traceGrpcExporter(ctx, cfg.Exporter)
case ExporterTypeHTTP:
exporter, err = traceHttpExporter(ctx, cfg.Exporter)
case ExporterTypeGoogle:
exporter, err = traceGoogleExporter(ctx, cfg.Exporter)
case ExporterTypePrometheus:
fallthrough // prometheus is not supported for logs
default:
err = errExporterType(cfg.Exporter.Type, "tracer")
}
if err != nil {
return nil, fmt.Errorf("trace exporter: %w", err)
}
sampler := newSampler(cfg)
opts := []sdk_trace.TracerProviderOption{
sdk_trace.WithResource(resource),
sdk_trace.WithSampler(sampler),
}
if exporter != nil {
opts = append(opts, sdk_trace.WithBatcher(exporter))
}
tracerProvider := sdk_trace.NewTracerProvider(opts...)
return tracerProvider, nil
}
func traceStdOutExporter(cfg ExporterConfig) (sdk_trace.SpanExporter, error) {
options := []stdouttrace.Option{
stdouttrace.WithPrettyPrint(),
}
if cfg.Type == ExporterTypeStdErr {
options = append(options, stdouttrace.WithWriter(os.Stderr))
}
exporter, err := stdouttrace.New(options...)
if err != nil {
return nil, err
}
return exporter, nil
}
func traceGrpcExporter(ctx context.Context, cfg ExporterConfig) (sdk_trace.SpanExporter, error) {
var grpcOpts []otlptracegrpc.Option
if cfg.Endpoint != "" {
grpcOpts = append(grpcOpts, otlptracegrpc.WithEndpoint(cfg.Endpoint))
}
if cfg.Insecure {
grpcOpts = append(grpcOpts, otlptracegrpc.WithInsecure())
}
exporter, err := otlptracegrpc.New(ctx, grpcOpts...)
if err != nil {
return nil, err
}
return exporter, nil
}
func traceHttpExporter(ctx context.Context, cfg ExporterConfig) (sdk_trace.SpanExporter, error) {
var httpOpts []otlptracehttp.Option
if cfg.Endpoint != "" {
httpOpts = append(httpOpts, otlptracehttp.WithEndpoint(cfg.Endpoint))
}
if cfg.Insecure {
httpOpts = append(httpOpts, otlptracehttp.WithInsecure())
}
exporter, err := otlptracehttp.New(ctx, httpOpts...)
if err != nil {
return nil, err
}
return exporter, nil
}
func traceGoogleExporter(ctx context.Context, cfg ExporterConfig) (sdk_trace.SpanExporter, error) {
exporter, err := google_trace.New(
google_trace.WithContext(ctx),
google_trace.WithProjectID(cfg.GoogleProjectID),
)
if err != nil {
return nil, err
}
return exporter, nil
}
// newSampler returns a sampler decorator which behaves differently,
// based on the parent of the span. If the span has no parent and is of kind server,
// the decorated sampler is used to make sampling decision.
// If the span has a parent, depending on whether the parent is remote and whether it
// is sampled, one of the following samplers will apply:
// - remote parent sampled -> always sample
// - remote parent not sampled -> sample based on the decorated sampler (fraction based)
// - local parent sampled -> always sample
// - local parent not sampled -> never sample
func newSampler(cfg TraceConfig) sdk_trace.Sampler {
fraction := sdk_trace.TraceIDRatioBased(cfg.Fraction)
return sdk_trace.ParentBased(
spanKindBased(fraction, trace.SpanKindServer),
sdk_trace.WithRemoteParentNotSampled(fraction),
)
}
type spanKindSampler struct {
sampler sdk_trace.Sampler
kinds []trace.SpanKind
}
// ShouldSample implements the [sdk_trace.Sampler] interface.
// It will not sample any spans which do not match the configured span kinds.
// For spans which do match, the decorated sampler is used to make the sampling decision.
func (sk spanKindSampler) ShouldSample(p sdk_trace.SamplingParameters) sdk_trace.SamplingResult {
psc := trace.SpanContextFromContext(p.ParentContext)
if !slices.Contains(sk.kinds, p.Kind) {
return sdk_trace.SamplingResult{
Decision: sdk_trace.Drop,
Tracestate: psc.TraceState(),
}
}
s := sk.sampler.ShouldSample(p)
return s
}
func (sk spanKindSampler) Description() string {
return fmt.Sprintf("SpanKindBased{sampler:%s,kinds:%v}",
sk.sampler.Description(),
sk.kinds,
)
}
// spanKindBased returns a sampler decorator which behaves differently, based on the kind of the span.
// If the span kind does not match one of the configured kinds, it will not be sampled.
// If the span kind matches, the decorated sampler is used to make sampling decision.
func spanKindBased(sampler sdk_trace.Sampler, kinds ...trace.SpanKind) sdk_trace.Sampler {
return spanKindSampler{
sampler: sampler,
kinds: kinds,
}
}