mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-05-01 23:32:36 +02:00
Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates
This commit is contained in:
parent
694e32be26
commit
41cbfd6e0a
178 changed files with 36008 additions and 399 deletions
83
internal/infrastructure/tracing/middleware.go
Normal file
83
internal/infrastructure/tracing/middleware.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package tracing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// HTTPMiddleware creates spans for each HTTP request.
|
||||
// Extracts trace context from incoming headers and sets span attributes.
|
||||
func HTTPMiddleware(next http.Handler) http.Handler {
|
||||
tracer := otel.Tracer("sentinel-soc/http")
|
||||
propagator := otel.GetTextMapPropagator()
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract trace context from incoming headers.
|
||||
ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
|
||||
|
||||
spanName := fmt.Sprintf("%s %s", r.Method, r.URL.Path)
|
||||
ctx, span := tracer.Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(
|
||||
attribute.String("http.method", r.Method),
|
||||
attribute.String("http.url", r.URL.String()),
|
||||
attribute.String("http.target", r.URL.Path),
|
||||
attribute.String("http.user_agent", r.UserAgent()),
|
||||
attribute.String("net.host.name", r.Host),
|
||||
),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Wrap response writer to capture status code.
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r.WithContext(ctx))
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("http.status_code", sw.status),
|
||||
)
|
||||
if sw.status >= 400 {
|
||||
span.SetAttributes(attribute.Bool("error", true))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter captures the HTTP status code for span attributes.
|
||||
// Implements http.Flusher to support SSE/streaming through middleware chain.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (sw *statusWriter) WriteHeader(code int) {
|
||||
if !sw.wroteHeader {
|
||||
sw.status = code
|
||||
sw.wroteHeader = true
|
||||
}
|
||||
sw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (sw *statusWriter) Write(b []byte) (int, error) {
|
||||
if !sw.wroteHeader {
|
||||
sw.wroteHeader = true
|
||||
}
|
||||
return sw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush delegates to the underlying ResponseWriter if it supports http.Flusher.
|
||||
// Required for SSE streaming endpoints to work through the middleware chain.
|
||||
func (sw *statusWriter) Flush() {
|
||||
if f, ok := sw.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying ResponseWriter for Go 1.20+ ResponseController.
|
||||
func (sw *statusWriter) Unwrap() http.ResponseWriter {
|
||||
return sw.ResponseWriter
|
||||
}
|
||||
91
internal/infrastructure/tracing/tracing.go
Normal file
91
internal/infrastructure/tracing/tracing.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Package tracing provides OpenTelemetry instrumentation for the SOC platform.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 go run ./cmd/soc/
|
||||
//
|
||||
// If OTEL_EXPORTER_OTLP_ENDPOINT is not set, tracing is disabled (noop).
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
ServiceName = "sentinel-soc"
|
||||
ServiceVersion = "1.0.0"
|
||||
)
|
||||
|
||||
// InitTracer sets up the OpenTelemetry TracerProvider with OTLP gRPC exporter.
|
||||
// Returns the provider (for shutdown) and any error.
|
||||
// If endpoint is empty, returns a noop provider (safe to use, no overhead).
|
||||
func InitTracer(ctx context.Context, endpoint string) (*sdktrace.TracerProvider, error) {
|
||||
if endpoint == "" {
|
||||
slog.Info("tracing disabled: OTEL_EXPORTER_OTLP_ENDPOINT not set")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exporter, err := otlptracegrpc.New(ctx,
|
||||
otlptracegrpc.WithEndpoint(endpoint),
|
||||
otlptracegrpc.WithInsecure(), // Use TLS in production
|
||||
otlptracegrpc.WithTimeout(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := resource.New(ctx,
|
||||
resource.WithAttributes(
|
||||
semconv.ServiceName(ServiceName),
|
||||
semconv.ServiceVersion(ServiceVersion),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter,
|
||||
sdktrace.WithMaxQueueSize(2048),
|
||||
sdktrace.WithBatchTimeout(5*time.Second),
|
||||
),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(1.0))),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
slog.Info("tracing enabled",
|
||||
"endpoint", endpoint,
|
||||
"service", ServiceName,
|
||||
"version", ServiceVersion,
|
||||
)
|
||||
|
||||
return tp, nil
|
||||
}
|
||||
|
||||
// Tracer returns a named tracer from the global provider.
|
||||
func Tracer(name string) trace.Tracer {
|
||||
return otel.Tracer(name)
|
||||
}
|
||||
|
||||
// Shutdown gracefully flushes and stops the tracer provider.
|
||||
func Shutdown(ctx context.Context, tp *sdktrace.TracerProvider) {
|
||||
if tp == nil {
|
||||
return
|
||||
}
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("tracer shutdown error", "error", err)
|
||||
}
|
||||
}
|
||||
106
internal/infrastructure/tracing/tracing_test.go
Normal file
106
internal/infrastructure/tracing/tracing_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- InitTracer Tests ---
|
||||
|
||||
func TestInitTracer_NoopWhenEndpointEmpty(t *testing.T) {
|
||||
tp, err := InitTracer(context.Background(), "")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, tp, "empty endpoint should return nil TracerProvider (noop)")
|
||||
}
|
||||
|
||||
func TestShutdown_NilProvider_NoPanic(t *testing.T) {
|
||||
// Should not panic when called with nil.
|
||||
assert.NotPanics(t, func() {
|
||||
Shutdown(context.Background(), nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTracer_ReturnsNonNil(t *testing.T) {
|
||||
tr := Tracer("test-tracer")
|
||||
assert.NotNil(t, tr)
|
||||
}
|
||||
|
||||
// --- HTTPMiddleware Tests ---
|
||||
|
||||
func TestHTTPMiddleware_SetsStatusCode(t *testing.T) {
|
||||
handler := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write([]byte("created"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/soc/event", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
assert.Equal(t, "created", rr.Body.String())
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_Default200(t *testing.T) {
|
||||
handler := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestHTTPMiddleware_ErrorStatus(t *testing.T) {
|
||||
handler := HTTPMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rr.Code)
|
||||
}
|
||||
|
||||
// --- statusWriter Tests ---
|
||||
|
||||
func TestStatusWriter_DefaultStatus(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := &statusWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
assert.Equal(t, http.StatusOK, sw.status)
|
||||
assert.False(t, sw.wroteHeader)
|
||||
}
|
||||
|
||||
func TestStatusWriter_WriteHeaderOnce(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := &statusWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
|
||||
sw.WriteHeader(http.StatusNotFound)
|
||||
assert.Equal(t, http.StatusNotFound, sw.status)
|
||||
assert.True(t, sw.wroteHeader)
|
||||
|
||||
// Second call should NOT change status.
|
||||
sw.WriteHeader(http.StatusCreated)
|
||||
assert.Equal(t, http.StatusNotFound, sw.status, "status should not change on second WriteHeader")
|
||||
}
|
||||
|
||||
func TestStatusWriter_WriteImplicitHeader(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := &statusWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
|
||||
n, err := sw.Write([]byte("hello"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 5, n)
|
||||
assert.True(t, sw.wroteHeader, "Write should set wroteHeader")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue