feat: add independent platform modules and multi-provider storage

This commit is contained in:
2026-09-23 14:51:14 +08:00
parent f91034477b
commit 208a9c3fbd
57 changed files with 4890 additions and 48 deletions

78
telemetry/adapters.go Normal file
View File

@@ -0,0 +1,78 @@
package telemetry
import (
"context"
"database/sql"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"io"
"net"
"sync"
)
type spanBody struct {
io.ReadCloser
span trace.Span
once sync.Once
}
func (b *spanBody) finish(err error) {
b.once.Do(func() {
if err != nil && err != io.EOF {
b.span.SetStatus(codes.Error, "body read failed")
}
b.span.End()
})
}
func (b *spanBody) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
if err != nil {
b.finish(err)
}
return n, err
}
func (b *spanBody) Close() error { err := b.ReadCloser.Close(); b.finish(err); return err }
type RedisHook struct{ Runtime *Runtime }
func (h RedisHook) DialHook(next redis.DialHook) redis.DialHook {
return func(ctx context.Context, network, addr string) (conn net.Conn, err error) {
err = h.Runtime.Do(ctx, "redis.connect", func(ctx context.Context) error { conn, err = next(ctx, network, addr); return err })
return
}
}
func (h RedisHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
var result error
_ = h.Runtime.Do(ctx, "redis.command", func(ctx context.Context) error {
result = next(ctx, cmd)
if result == redis.Nil {
return nil
}
return result
})
return result
}
}
func (h RedisHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return func(ctx context.Context, cmds []redis.Cmder) error {
return h.Runtime.Do(ctx, "redis.pipeline", func(ctx context.Context) error { return next(ctx, cmds) })
}
}
// SQL records execution spans without SQL text or arguments. Query spans cover
// obtaining rows; iteration/Scan remain caller-owned. Use Do around transactions.
type SQL struct {
DB *sql.DB
Runtime *Runtime
}
func (d SQL) ExecContext(ctx context.Context, query string, args ...any) (result sql.Result, err error) {
err = d.Runtime.Do(ctx, "db.exec", func(ctx context.Context) error { result, err = d.DB.ExecContext(ctx, query, args...); return err })
return
}
func (d SQL) QueryContext(ctx context.Context, query string, args ...any) (rows *sql.Rows, err error) {
err = d.Runtime.Do(ctx, "db.query", func(ctx context.Context) error { rows, err = d.DB.QueryContext(ctx, query, args...); return err })
return
}

187
telemetry/telemetry.go Normal file
View File

@@ -0,0 +1,187 @@
// Package telemetry adds opt-in OpenTelemetry without process-global providers.
package telemetry
import (
"context"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"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"
"go.opentelemetry.io/otel/trace"
"math"
"net/http"
"time"
)
type Config struct {
Service string `json:"service"`
SampleRatio float64 `json:"sampleRatio"`
Exporter sdktrace.SpanExporter `json:"-"`
Reader sdkmetric.Reader `json:"-"`
}
type Runtime struct {
traces *sdktrace.TracerProvider
meters *sdkmetric.MeterProvider
tracer trace.Tracer
propagator propagation.TextMapPropagator
duration metric.Float64Histogram
requests metric.Int64Counter
}
func New(cfg Config) (*Runtime, error) {
if cfg.Service == "" || math.IsNaN(cfg.SampleRatio) || cfg.SampleRatio < 0 || cfg.SampleRatio > 1 {
return nil, errors.New("telemetry: service and sampleRatio in 0..1 required")
}
res := resource.NewSchemaless(attribute.String("service.name", cfg.Service))
traceOptions := []sdktrace.TracerProviderOption{sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SampleRatio)))}
if cfg.Exporter != nil {
traceOptions = append(traceOptions, sdktrace.WithBatcher(cfg.Exporter, sdktrace.WithMaxQueueSize(512), sdktrace.WithMaxExportBatchSize(128), sdktrace.WithExportTimeout(3*time.Second)))
}
metricOptions := []sdkmetric.Option{sdkmetric.WithResource(res)}
if cfg.Reader != nil {
metricOptions = append(metricOptions, sdkmetric.WithReader(cfg.Reader))
}
r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagation.TraceContext{}}
r.tracer = r.traces.Tracer("git.yuntuo.hk/k2safe/go-omni")
meter := r.meters.Meter("git.yuntuo.hk/k2safe/go-omni")
r.duration, _ = meter.Float64Histogram("http.server.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10))
r.requests, _ = meter.Int64Counter("http.server.requests")
return r, nil
}
func (r *Runtime) Start(ctx context.Context, name string, options ...trace.SpanStartOption) (context.Context, trace.Span) {
ctx, span := r.tracer.Start(ctx, name, options...)
ctx = logx.WithTraceID(ctx, span.SpanContext().TraceID().String())
return ctx, span
}
func (r *Runtime) Do(ctx context.Context, name string, work func(context.Context) error) (err error) {
if ctx == nil || work == nil || name == "" {
return errors.New("telemetry: context, operation name and work required")
}
ctx, span := r.Start(ctx, name)
defer span.End()
defer func() {
if p := recover(); p != nil {
span.SetStatus(codes.Error, "panic")
panic(p)
}
}()
err = work(ctx)
if err != nil {
span.SetStatus(codes.Error, "operation failed")
}
return err
}
func (r *Runtime) Middleware() httpx.Middleware {
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) (err error) {
parent := c.Context()
ctx := r.propagator.Extract(parent, propagation.HeaderCarrier(c.Request().Header))
route := httpx.RoutePattern(parent)
if route == "" {
route = "unmatched"
}
method := c.Request().Method
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
default:
method = "OTHER"
}
ctx, span := r.Start(ctx, method+" "+route, trace.WithSpanKind(trace.SpanKindServer))
c.WithContext(ctx)
started := time.Now()
defer func() {
status := c.Status()
if err != nil && !c.Responded() {
status = apierrors.From(err).Status
}
p := recover()
if p != nil {
status = 500
}
attrs := []attribute.KeyValue{attribute.String("http.request.method", method), attribute.String("http.route", route), attribute.Int("http.response.status_code", status)}
span.SetAttributes(attrs...)
if status >= 500 {
span.SetStatus(codes.Error, "request failed")
}
r.requests.Add(ctx, 1, metric.WithAttributes(attrs...))
r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...))
span.End()
// Preserve correlation for outer access-log middleware without
// exposing an ended span as its active span.
c.WithContext(logx.WithTraceID(parent, span.SpanContext().TraceID().String()))
if p != nil {
panic(p)
}
}()
return next(c)
}
}
}
func (r *Runtime) Shutdown(ctx context.Context) error {
return errors.Join(r.traces.Shutdown(ctx), r.meters.Shutdown(ctx))
}
func (r *Runtime) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return r.Shutdown(ctx)
}
func (r *Runtime) Flush(ctx context.Context) error {
return errors.Join(r.traces.ForceFlush(ctx), r.meters.ForceFlush(ctx))
}
// Transport borrows Base. Headers are injected on a clone, and the span lives
// until EOF/Close. URL paths, query strings and credentials are never recorded.
type Transport struct {
Runtime *Runtime
Base http.RoundTripper
}
func (t Transport) RoundTrip(request *http.Request) (*http.Response, error) {
if t.Runtime == nil {
return nil, errors.New("telemetry: runtime required")
}
ctx, span := t.Runtime.Start(request.Context(), "http.client", trace.WithSpanKind(trace.SpanKindClient))
defer func() {
if p := recover(); p != nil {
span.SetStatus(codes.Error, "panic")
span.End()
panic(p)
}
}()
req := request.Clone(ctx)
if req.Header == nil {
req.Header = make(http.Header)
}
t.Runtime.propagator.Inject(ctx, propagation.HeaderCarrier(req.Header))
base := t.Base
if base == nil {
base = http.DefaultTransport
}
response, err := base.RoundTrip(req)
if err != nil {
span.SetStatus(codes.Error, "transport failed")
span.End()
if response != nil && response.Body != nil {
response.Body.Close()
}
return nil, err
}
if response == nil || response.Body == nil {
span.SetStatus(codes.Error, "invalid response")
span.End()
return nil, errors.New("telemetry: invalid transport response")
}
span.SetAttributes(attribute.Int("http.response.status_code", response.StatusCode))
if response.StatusCode >= 500 {
span.SetStatus(codes.Error, "upstream failed")
}
response.Body = &spanBody{ReadCloser: response.Body, span: span}
return response, nil
}

117
telemetry/telemetry_test.go Normal file
View File

@@ -0,0 +1,117 @@
package telemetry_test
import (
"context"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/telemetry"
"github.com/redis/go-redis/v9"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHTTPTracePropagationMetricsAndRedisNil(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
reader := sdkmetric.NewManualReader()
runtime, err := telemetry.New(telemetry.Config{Service: "test", SampleRatio: 1, Exporter: exporter, Reader: reader})
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
ctx := context.Background()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("Traceparent"), "00-11111111111111111111111111111111-") {
t.Error("missing propagated trace")
}
io.WriteString(w, "ok")
}))
defer upstream.Close()
app := omni.New(omni.WithLogger(false))
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
err := next(c)
if logx.TraceID(c.Context()) != "11111111111111111111111111111111" {
t.Error("outer access log lost trace correlation")
}
return err
}
})
app.Use(runtime.Middleware())
app.GET("/users/:id", func(c *httpx.Context) error {
if logx.TraceID(c.Context()) != "11111111111111111111111111111111" {
t.Error("log correlation")
}
req, _ := http.NewRequestWithContext(c.Context(), "GET", upstream.URL+"?secret=hidden", nil)
resp, err := (telemetry.Transport{Runtime: runtime}).RoundTrip(req)
if err != nil {
return err
}
io.ReadAll(resp.Body)
resp.Body.Close()
if req.Header.Get("Traceparent") != "" {
t.Error("caller headers mutated")
}
return c.OK("ok")
})
req := httptest.NewRequest("GET", "/users/privateUser?secret=hidden", nil)
req.Header.Set("Traceparent", "00-11111111111111111111111111111111-2222222222222222-01")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatal(rec.Code)
}
hook := telemetry.RedisHook{Runtime: runtime}
if err = hook.ProcessHook(func(context.Context, redis.Cmder) error { return redis.Nil })(ctx, redis.NewStringCmd(ctx, "GET", "secret")); err != redis.Nil {
t.Fatal("cache miss was swallowed", err)
}
if err = runtime.Flush(ctx); err != nil {
t.Fatal(err)
}
spans := exporter.GetSpans()
if len(spans) != 3 {
t.Fatalf("got %d spans", len(spans))
}
var client, server bool
for _, s := range spans {
if strings.Contains(s.Name, "privateUser") || strings.Contains(s.Name, "secret") {
t.Fatal(s.Name)
}
if s.SpanKind == trace.SpanKindServer {
server = true
if s.Name != "GET /users/:id" || s.Parent.SpanID().String() != "2222222222222222" {
t.Fatal(s.Name, s.Parent)
}
}
if s.SpanKind == trace.SpanKindClient {
client = true
}
}
if !client || !server {
t.Fatal("missing client/server span")
}
var metrics metricdata.ResourceMetrics
if err = reader.Collect(ctx, &metrics); err != nil {
t.Fatal(err)
}
var count int64
for _, scope := range metrics.ScopeMetrics {
for _, m := range scope.Metrics {
if sum, ok := m.Data.(metricdata.Sum[int64]); ok {
for _, p := range sum.DataPoints {
count += p.Value
}
}
}
}
if count != 1 {
t.Fatal("request metric", count)
}
}