chore(api-gateway): merge review docs and update dependencies
This commit is contained in:
77
services/push-gateway/internal/observability/logger.go
Normal file
77
services/push-gateway/internal/observability/logger.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Package observability provides slog-based structured logging for push-gateway.
|
||||
//
|
||||
// The logger emits JSON in production and a human-readable console format in
|
||||
// development (DEV_MODE=true). Trace correlation is handled by attaching a
|
||||
// trace_id attribute from the OpenTelemetry span context when present.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type so context values cannot collide with callers.
|
||||
type ctxKey struct{}
|
||||
|
||||
// logger is the process-wide *slog.Logger initialized by InitLogger.
|
||||
var logger *slog.Logger
|
||||
|
||||
// InitLogger configures the global slog logger. In DevMode a console handler
|
||||
// with colored levels is used; otherwise a JSON handler at Info level is used.
|
||||
// The logger is tagged with service=push-gateway on every emitted record.
|
||||
func InitLogger(devMode bool) *slog.Logger {
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}
|
||||
if devMode {
|
||||
opts.Level = slog.LevelDebug
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
}
|
||||
logger = slog.New(handler).With("service", "push-gateway")
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
// Logger returns the process-wide logger initialized by InitLogger. When Init
|
||||
// has not been called a fallback JSON logger is returned so callers never
|
||||
// receive a nil logger.
|
||||
func Logger() *slog.Logger {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)).With("service", "push-gateway")
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
// WithContext returns a copy of ctx carrying l so it can later be retrieved
|
||||
// via FromContext.
|
||||
func WithContext(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, l)
|
||||
}
|
||||
|
||||
// FromContext returns the logger stored in ctx. When an OpenTelemetry span is
|
||||
// active the returned logger is decorated with a trace_id field so log lines
|
||||
// can be correlated to traces. If no logger was stored in ctx the
|
||||
// process-wide logger is returned (never nil).
|
||||
func FromContext(ctx context.Context) *slog.Logger {
|
||||
l, ok := ctx.Value(ctxKey{}).(*slog.Logger)
|
||||
if !ok || l == nil {
|
||||
l = Logger()
|
||||
}
|
||||
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
|
||||
return l.With("trace_id", sc.TraceID().String())
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Now returns the current UTC time in RFC3339 format. Convenience helper for
|
||||
// log field construction.
|
||||
func Now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
124
services/push-gateway/internal/observability/metrics.go
Normal file
124
services/push-gateway/internal/observability/metrics.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Package observability also exposes Prometheus metrics for push-gateway.
|
||||
//
|
||||
// The metrics cover the full surface area described in 02 §6.4: active
|
||||
// connections, messages pushed/dropped, heartbeats, disconnects, Redis Pub/Sub
|
||||
// latency, Kafka consumption and Redis SET rebuilds. All metrics are
|
||||
// registered with the global prometheus.DefaultRegisterer on package init.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// Metrics holds all push-gateway Prometheus metric handles.
|
||||
type Metrics struct {
|
||||
ActiveConnections prometheus.Gauge
|
||||
MessagesPushed *prometheus.CounterVec
|
||||
MessagesDropped *prometheus.CounterVec
|
||||
Heartbeats prometheus.Counter
|
||||
Disconnects *prometheus.CounterVec
|
||||
RedisPubSubLatency *prometheus.HistogramVec
|
||||
KafkaConsumed *prometheus.CounterVec
|
||||
RedisSetRebuild prometheus.Counter
|
||||
ConnectionsPerUser prometheus.Gauge
|
||||
}
|
||||
|
||||
// metrics is the process-wide metric set, initialized once by NewMetrics.
|
||||
var metrics *Metrics
|
||||
|
||||
// NewMetrics registers and returns the push-gateway metric set. Subsequent
|
||||
// calls return the same instance to avoid duplicate-registration panics.
|
||||
func NewMetrics() *Metrics {
|
||||
if metrics != nil {
|
||||
return metrics
|
||||
}
|
||||
metrics = &Metrics{
|
||||
ActiveConnections: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "active_connections",
|
||||
Help: "Current number of live WebSocket connections.",
|
||||
}),
|
||||
MessagesPushed: promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "messages_pushed_total",
|
||||
Help: "Total messages pushed, partitioned by event and result.",
|
||||
}, []string{"event", "result"}),
|
||||
MessagesDropped: promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "messages_dropped_total",
|
||||
Help: "Messages dropped, partitioned by reason.",
|
||||
}, []string{"reason"}),
|
||||
Heartbeats: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "heartbeat_total",
|
||||
Help: "Total WebSocket Ping heartbeats received.",
|
||||
}),
|
||||
Disconnects: promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "disconnect_total",
|
||||
Help: "Disconnects, partitioned by reason.",
|
||||
}, []string{"reason"}),
|
||||
RedisPubSubLatency: promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "redis_pubsub_latency_seconds",
|
||||
Help: "Redis Pub/Sub round-trip latency in seconds.",
|
||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 12),
|
||||
}, []string{"direction"}),
|
||||
KafkaConsumed: promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "kafka_consumed_total",
|
||||
Help: "Kafka messages consumed, partitioned by topic and partition.",
|
||||
}, []string{"topic", "partition"}),
|
||||
RedisSetRebuild: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "redis_set_rebuild_total",
|
||||
Help: "Total Redis online-presence SET rebuilds (startup or recovery).",
|
||||
}),
|
||||
ConnectionsPerUser: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "push_gateway",
|
||||
Name: "connections_per_user_max",
|
||||
Help: "Maximum concurrent connections held by a single user.",
|
||||
}),
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
// Metrics returns the process-wide metric set. When NewMetrics has not been
|
||||
// called a new set is registered on demand.
|
||||
func MetricsInstance() *Metrics {
|
||||
if metrics == nil {
|
||||
return NewMetrics()
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
// IncMessageDropped is a convenience helper for the channel-full case.
|
||||
func (m *Metrics) IncMessageDropped(reason string) {
|
||||
m.MessagesDropped.WithLabelValues(reason).Inc()
|
||||
}
|
||||
|
||||
// IncPushed is a convenience helper for the push result counter.
|
||||
func (m *Metrics) IncPushed(event, result string) {
|
||||
m.MessagesPushed.WithLabelValues(event, result).Inc()
|
||||
}
|
||||
|
||||
// IncDisconnect is a convenience helper for the disconnect counter.
|
||||
func (m *Metrics) IncDisconnect(reason string) {
|
||||
m.Disconnects.WithLabelValues(reason).Inc()
|
||||
}
|
||||
|
||||
// ObservePubSubLatency records a Redis Pub/Sub round-trip duration.
|
||||
func (m *Metrics) ObservePubSubLatency(direction string, seconds float64) {
|
||||
m.RedisPubSubLatency.WithLabelValues(direction).Observe(seconds)
|
||||
}
|
||||
|
||||
// IncKafkaConsumed increments the Kafka consumed counter.
|
||||
func (m *Metrics) IncKafkaConsumed(topic, partition string) {
|
||||
m.KafkaConsumed.WithLabelValues(topic, partition).Inc()
|
||||
}
|
||||
|
||||
// IncRedisSetRebuild increments the SET rebuild counter (ISSUE-058 metric).
|
||||
func (m *Metrics) IncRedisSetRebuild() {
|
||||
m.RedisSetRebuild.Inc()
|
||||
}
|
||||
@@ -1,73 +1,35 @@
|
||||
// Package observability also bootstraps OpenTelemetry tracing. The tracer is
|
||||
// initialized via shared-go/tracer so push-gateway and api-gateway share the
|
||||
// same SDK configuration.
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"github.com/edu-cloud/shared-go/tracer"
|
||||
)
|
||||
|
||||
// InitTracer 初始化 OpenTelemetry tracer.
|
||||
//
|
||||
// endpoint 为 "http://host:port" 格式(如 "http://localhost:4318");
|
||||
// 为空时跳过初始化(tracing disabled)。
|
||||
//
|
||||
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span.
|
||||
// InitTracer initializes the OTel tracer using shared-go/tracer. When the
|
||||
// OTLP endpoint is empty (or "localhost:4318") the exporter still runs but
|
||||
// emits no spans if no collector is listening. Returns a shutdown function
|
||||
// that must be called on process exit.
|
||||
func InitTracer(serviceName, endpoint string) func() {
|
||||
if endpoint == "" {
|
||||
log.Println("OTEL endpoint not set, tracing disabled")
|
||||
// shared-go/tracer reads OTEL_EXPORTER_OTLP_ENDPOINT directly; override it
|
||||
// explicitly so push-gateway's own config value takes precedence.
|
||||
if endpoint != "" {
|
||||
_ = os.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", endpoint)
|
||||
}
|
||||
if err := tracer.Init(serviceName); err != nil {
|
||||
// Tracing is best-effort: log and continue without a tracer rather than
|
||||
// failing the whole process.
|
||||
fmt.Fprintf(os.Stderr, "push-gateway: tracer init failed: %v (tracing disabled)\n", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
log.Printf("invalid OTEL endpoint %q, tracing disabled", endpoint)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exporter, err := otlptracehttp.New(ctx,
|
||||
otlptracehttp.WithEndpoint(u.Host),
|
||||
otlptracehttp.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create OTLP exporter: %v, tracing disabled", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
res, err := resource.New(ctx,
|
||||
resource.WithAttributes(semconv.ServiceName(serviceName)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("failed to create resource: %v", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
log.Printf("OpenTelemetry tracer initialized for %s (endpoint=%s)", serviceName, u.Host)
|
||||
return func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("failed to shutdown tracer: %v", err)
|
||||
if err := tracer.Shutdown(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "push-gateway: tracer shutdown failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user