Files
substrate/internal/serverboot/serverboot.go
T

182 lines
6.4 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package serverboot collects the startup boilerplate shared by the
// long-running substrate server binaries (ateapi, atelet, ateom-gvisor):
// slog wiring, OTel tracer + meter providers, a Prometheus + /readyz
// HTTP surface, and a couple of small helpers for startup fail-fast.
package serverboot
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"github.com/agent-substrate/substrate/internal/contextlogging"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)
// InitLogger sets the global slog logger to a JSON handler wrapped in
// contextlogging.NewHandler. Call once at process start.
func InitLogger() {
slog.SetDefault(slog.New(contextlogging.NewHandler(slog.NewJSONHandler(os.Stdout, nil))))
}
// serviceInstanceID is generated once so the tracer and meter resources share it.
var serviceInstanceID = uuid.NewString()
// newResource builds the resource shared by the tracer and meter providers.
// WithFromEnv is last so OTEL_* env vars override the defaults.
func newResource(ctx context.Context, serviceName string) (*resource.Resource, error) {
res, err := resource.New(ctx,
resource.WithTelemetrySDK(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceInstanceID(serviceInstanceID),
),
resource.WithFromEnv(),
)
if errors.Is(err, resource.ErrPartialResource) || errors.Is(err, resource.ErrSchemaURLConflict) {
slog.WarnContext(ctx, "partial telemetry resource", slog.Any("err", err))
} else if err != nil {
return nil, err
}
return res, nil
}
// TracingOptions configures InitTracing.
type TracingOptions struct {
// ServiceName is required; populates resource.semconv ServiceName.
ServiceName string
// Sampler is required. ateapi typically uses ParentBased(AlwaysSample);
// atelet/ateom-gvisor use ParentBased(NeverSample).
Sampler sdktrace.Sampler
}
// InitTracing registers a global TracerProvider with the given options
// and the TraceContext text-map propagator.
func InitTracing(ctx context.Context, opts TracingOptions) (*sdktrace.TracerProvider, error) {
if opts.ServiceName == "" {
return nil, fmt.Errorf("TracingOptions.ServiceName is required")
}
res, err := newResource(ctx, opts.ServiceName)
if err != nil {
return nil, fmt.Errorf("create tracer resource: %w", err)
}
tpOpts := []sdktrace.TracerProviderOption{
sdktrace.WithResource(res),
sdktrace.WithSampler(opts.Sampler),
}
exporter, err := otlptracegrpc.New(ctx,
// GKE managed traces doesn't support validating the TLS certs of the collector.
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("create OTLP exporter: %w", err)
}
tpOpts = append(tpOpts, sdktrace.WithBatcher(exporter))
tp := sdktrace.NewTracerProvider(tpOpts...)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp, nil
}
// InitMetrics registers a global MeterProvider with both a Prometheus
// reader (exposed via StartMetricsServer's /metrics handler) and an
// OTLP periodic reader.
func InitMetrics(ctx context.Context, serviceName string) (*sdkmetric.MeterProvider, error) {
if serviceName == "" {
return nil, fmt.Errorf("serviceName is required")
}
promExporter, err := prometheus.New()
if err != nil {
return nil, fmt.Errorf("create Prometheus metric exporter: %w", err)
}
otlpExporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithInsecure())
if err != nil {
return nil, fmt.Errorf("create OTLP metric exporter: %w", err)
}
res, err := newResource(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("create metric resource: %w", err)
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(promExporter),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(otlpExporter)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)
return mp, nil
}
// Fatal logs msg + err and exits with status 1. For startup-time
// fail-fast where there's no recovery path.
func Fatal(ctx context.Context, msg string, err error) {
slog.ErrorContext(ctx, msg, slog.Any("err", err))
os.Exit(1)
}
// ShutdownProvider invokes the OTel provider's Shutdown and logs any
// error. Designed to be deferred from main():
//
// defer serverboot.ShutdownProvider("TracerProvider", tp.Shutdown)
func ShutdownProvider(name string, shutdown func(context.Context) error) {
if err := shutdown(context.Background()); err != nil {
slog.Error("Failed to shutdown "+name, slog.Any("err", err))
}
}
// MetricsServerOptions configures StartMetricsServer.
type MetricsServerOptions struct {
// Addr is the TCP listen address (e.g. ":9090").
Addr string
// EnableReadyz adds a /readyz handler that returns 200 OK. Some
// binaries (ateapi) want it for Kubernetes readiness probes; others
// (atelet) historically didn't surface one.
EnableReadyz bool
}
// StartMetricsServer runs an HTTP server exposing /metrics (Prometheus)
// and optionally /readyz. Blocks until http.ListenAndServe returns;
// designed to be `go`-launched.
func StartMetricsServer(ctx context.Context, opts MetricsServerOptions) {
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
if opts.EnableReadyz {
mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
}
slog.InfoContext(ctx, fmt.Sprintf("Starting Prometheus metrics server on %s", opts.Addr))
if err := http.ListenAndServe(opts.Addr, mux); err != nil {
slog.Error("Failed to start prometheus metrics server", slog.Any("err", err))
}
}