feat: add independent platform modules and multi-provider storage
This commit is contained in:
78
telemetry/adapters.go
Normal file
78
telemetry/adapters.go
Normal 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
187
telemetry/telemetry.go
Normal 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
117
telemetry/telemetry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user