Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions gormotel/gorm.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"regexp"
Expand Down Expand Up @@ -267,13 +268,17 @@ func (p *otelPlugin) after() gormHookFunc {
// 驱动跳过/流结束)不算失败,保持 Unset——与 Query/Exec 路径既有口径一致;
// Row 路径的查询期错误(rowConnPool 捞取)与回调链尾的 tx.Error 共用此判定,
// 避免两处口径漂移。
//
// 哨兵判定必须走 errors.Is(#111):switch == 是相等比较,经
// fmt.Errorf("%w") / 各类 Wrap 包装后的 gorm.ErrRecordNotFound(业务「未命中」
// 是最高频的被包装哨兵)此前不匹配,span 被 RecordError + SetStatus(Error),
// APM 上未命中查询全量报红,与本函数「业务语义性错误不算失败」的口径相反。
func recordSpanError(span trace.Span, err error) {
switch err {
case nil,
gorm.ErrRecordNotFound,
driver.ErrSkip,
io.EOF,
sql.ErrNoRows:
if err == nil ||
errors.Is(err, gorm.ErrRecordNotFound) ||
errors.Is(err, driver.ErrSkip) ||
errors.Is(err, io.EOF) ||
errors.Is(err, sql.ErrNoRows) {
return
}
span.RecordError(err)
Expand Down
103 changes: 102 additions & 1 deletion gormotel/gorm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package gormotel

import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -156,7 +161,6 @@ func TestOtelPlugin_StackTrace_NotPanic(t *testing.T) {
t.Fatal(err)
}
_ = db.Exec("SELECT 1").Error
_ = gorm.ErrRecordNotFound // 防止 unused import
}

// TestDoc_RowRowsSpanBoundaryDocumented 钉住 #101 的兜底契约:gorm 回调
Expand Down Expand Up @@ -333,6 +337,103 @@ func TestOtelPlugin_RowsSpanEndsAtCursorEstablishment(t *testing.T) {
}
}

// TestRecordSpanError_WrappedSentinelsStayUnset 钉住 #111 的回归:recordSpanError
// 必须用 errors.Is 判定哨兵——switch == 是相等比较,经 fmt.Errorf("%w") 包装后
// 的 gorm.ErrRecordNotFound(业务「未命中」是最高频的被包装哨兵)此前不匹配,
// span 被 RecordError + SetStatus(Error),APM 上未命中查询全量报红,与
// 「业务语义性错误不算失败」的口径相反。包装层级任意深都要命中(errors.Is
// 逐层拆 %w),其他三个哨兵(driver.ErrSkip / io.EOF / sql.ErrNoRows)同理。
func TestRecordSpanError_WrappedSentinelsStayUnset(t *testing.T) {
cases := []struct {
name string
err error
}{
{"nil", nil},
{"plain ErrRecordNotFound", gorm.ErrRecordNotFound},
{"wrapped ErrRecordNotFound", fmt.Errorf("get user by id: %w", gorm.ErrRecordNotFound)},
{"double wrapped ErrRecordNotFound", fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", gorm.ErrRecordNotFound))},
{"wrapped sql.ErrNoRows", fmt.Errorf("scan: %w", sql.ErrNoRows)},
{"plain driver.ErrSkip", driver.ErrSkip},
{"wrapped driver.ErrSkip", fmt.Errorf("exec: %w", driver.ErrSkip)},
{"plain io.EOF", io.EOF},
{"wrapped io.EOF", fmt.Errorf("stream: %w", io.EOF)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp))
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })

_, span := tp.Tracer("test").Start(context.Background(), "db.span")
recordSpanError(span, tc.err)
span.End()

spans := exp.GetSpans()
if len(spans) != 1 {
t.Fatalf("span count = %d, want 1", len(spans))
}
if got := spans[0].Status.Code; got != codes.Unset {
t.Errorf("span status = %v, want Unset(业务语义性错误不算失败,#111)", got)
}
if n := len(spans[0].Events); n != 0 {
t.Errorf("span error events = %d, want 0", n)
}
})
}
}

// TestRecordSpanError_RealErrorStillMarked 正向对照:非哨兵的真错误仍要
// RecordError + SetStatus(Error)——收敛判定口径时不得把报错面一起抹掉。
func TestRecordSpanError_RealErrorStillMarked(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp))
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })

boom := errors.New("connection refused")
_, span := tp.Tracer("test").Start(context.Background(), "db.span")
recordSpanError(span, fmt.Errorf("dial db: %w", boom)) // 非哨兵错误照常包装
span.End()

spans := exp.GetSpans()
if len(spans) != 1 {
t.Fatalf("span count = %d, want 1", len(spans))
}
if got := spans[0].Status.Code; got != codes.Error {
t.Errorf("span status = %v, want Error(真错误必须进 span)", got)
}
if len(spans[0].Events) == 0 {
t.Error("span must record the error event, got 0 events")
}
}

// TestOtelPlugin_FirstMissSpanUnset 走真实 gorm 链路的契约钉:First 未命中
// 时 tx.Error = gorm.ErrRecordNotFound,after 钩子的 span 必须保持 Unset——
// 「业务语义性错误不算失败」对 Query/Exec 路径同样成立(#111 口径回归钉)。
func TestOtelPlugin_FirstMissSpanUnset(t *testing.T) {
db, exp := newTestDBWithExporter(t)
if err := db.Exec("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)").Error; err != nil {
t.Fatal(err)
}

type item struct {
ID int64
Name string
}
var got item
err := db.Where("id = ?", 42).First(&got).Error
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("First on empty table err = %v, want gorm.ErrRecordNotFound", err)
}

span := findSpan(t, exp.GetSpans(), "select items")
if code := span.Status.Code; code != codes.Unset {
t.Errorf("span status = %v, want Unset(未命中查询不得报红,#111)", code)
}
if len(span.Events) != 0 {
t.Errorf("span error events = %d, want 0", len(span.Events))
}
}

// renameDialector 包装真实的 sqlite Dialector,只改写 Name() 的返回值
// ——让 gorm.Open 照常工作(真连接池/真 SQL 执行),而插件视角的
// 方言名是任意指定值。#78 的映射表用例靠它模拟 postgres/sqlserver/
Expand Down
68 changes: 45 additions & 23 deletions init.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,8 @@ func newGRPCExportingProviders(cfg Config, sampler sdktrace.Sampler, res *resour
}

// ---- tracing exporter ----
var traceOpts []otlptracegrpc.Option
traceOpts = append(traceOpts, otlptracegrpc.WithEndpoint(cfg.Endpoint))
if cfg.Insecure {
traceOpts = append(traceOpts, otlptracegrpc.WithInsecure())
}
if len(cfg.Headers) > 0 {
traceOpts = append(traceOpts, otlptracegrpc.WithHeaders(cfg.Headers))
}
traceOpts := applyCommonGrpcOpts(cfg.Endpoint, cfg,
otlptracegrpc.WithEndpoint, otlptracegrpc.WithInsecure, otlptracegrpc.WithHeaders)
traceExp, err := otlptrace.New(context.Background(), otlptracegrpc.NewClient(traceOpts...))
if err != nil {
return nil, nil, fmt.Errorf("tracing: create otlp trace exporter: %w", err)
Expand All @@ -286,33 +280,61 @@ func newGRPCExportingProviders(cfg Config, sampler sdktrace.Sampler, res *resour
if cfg.DisableMetrics {
return tp, nil, nil
}
var metricOpts []otlpmetricgrpc.Option
metricOpts = append(metricOpts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
if cfg.Insecure {
metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure())
}
if len(cfg.Headers) > 0 {
metricOpts = append(metricOpts, otlpmetricgrpc.WithHeaders(cfg.Headers))
}
metricOpts := applyCommonGrpcOpts(cfg.Endpoint, cfg,
otlpmetricgrpc.WithEndpoint, otlpmetricgrpc.WithInsecure, otlpmetricgrpc.WithHeaders)
metricExp, err := otlpmetricgrpc.New(context.Background(), metricOpts...)
if err != nil {
_ = tp.Shutdown(context.Background()) // 回滚已创建的 trace provider
return nil, nil, fmt.Errorf("tracing: create otlp metric exporter: %w", err)
}

mp, err := assembleMeterProvider(metricExp, res, tp)
if err != nil {
return nil, nil, err
}
return tp, mp, nil
}

// assembleMeterProvider 是 gRPC 与 HTTP 两条导出分支共用的 MeterProvider
// 装配尾段(#110,两处逐字复制收敛于此):周期读取器包住 metrics 导出器 →
// 建 provider → 启动 Go runtime 指标采集;启动失败时回滚本次与入参 tp 的
// trace 面,不返回半装配实例。「必须显式指定 provider」的关键注释随实现
// 单点维护:
//
// runtime.Start 必须显式指定 provider——无参 runtime.Start() 在调用时刻
// 捕获 otel.GetMeterProvider(),此刻全局还是 noop(SetMeterProvider 在
// Init 返回后才执行),runtime 指标全部静默丢失;重复 Init 时则挂到
// 上一轮已被 Shutdown 的 provider 上同样丢失
func assembleMeterProvider(metricExp metric.Exporter, res *resource.Resource, tp *sdktrace.TracerProvider) (*metric.MeterProvider, error) {
reader := metric.WithReader(metric.NewPeriodicReader(metricExp, metric.WithInterval(metricExportInterval)))
mp := metric.NewMeterProvider(reader, metric.WithResource(res))

// 必须显式指定 provider:无参 runtime.Start() 在调用时刻捕获
// otel.GetMeterProvider()——此刻全局还是 noop(SetMeterProvider 在
// Init 返回后才执行),runtime 指标全部静默丢失;重复 Init 时则挂到
// 上一轮已被 Shutdown 的 provider 上同样丢失
if err := runtime.Start(runtime.WithMeterProvider(mp)); err != nil {
_ = mp.Shutdown(context.Background())
_ = tp.Shutdown(context.Background())
return nil, nil, fmt.Errorf("tracing: start runtime metrics collector: %w", err)
return nil, fmt.Errorf("tracing: start runtime metrics collector: %w", err)
}
return tp, mp, nil
return mp, nil
}

// applyCommonGrpcOpts 收集 gRPC 分支 trace/metric 两组导出器逐字复制的连接
// 选项(Endpoint/Insecure/Headers,#110):Option 构造器由调用方传入,两组
// 选项的差异只剩「构造器属于哪条 signal」。
func applyCommonGrpcOpts[OPT any](
endpoint string,
cfg Config,
withEndpoint func(string) OPT,
withInsecure func() OPT,
withHeaders func(map[string]string) OPT,
) []OPT {
var opts []OPT
opts = append(opts, withEndpoint(endpoint))
if cfg.Insecure {
opts = append(opts, withInsecure())
}
if len(cfg.Headers) > 0 {
opts = append(opts, withHeaders(cfg.Headers))
}
return opts
}

// buildSampler 将 SampleRate 转换为采样器。
Expand Down
25 changes: 14 additions & 11 deletions otlphttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/url"
"strings"

"go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
Expand Down Expand Up @@ -41,7 +40,11 @@ type otlpHTTPEndpoint struct {
// 2. 基址 —— "https://host" / "https://host/api/public/otel":
// 后接标准 signal 路径 /v1/traces 与 /v1/metrics;
// 3. 无 scheme 的 host:port —— "otel-collector:4318":
// scheme 由 Config.Insecure 决定。
// scheme 由 Config.Insecure 决定。该形态不接受路径段:漏写 scheme 但
// 带基址路径("collector:4318/api/otel")直接报错并提示补 scheme(#112)
// ——此前路径段被整串吞进 host:port、basePath 恒空,导出打到根路径,
// /api/otel 段无声丢失(Init 成功、导出 404 循环),与 validateGRPCEndpoint
// 「不合规输入报错,不静默断供」的同一纪律相悖。
//
// 不接受 URL 内嵌凭据(http://user:pass@host):凭据走 Config.Headers——
// URL 里的凭据会进访问日志、代理日志与错误栈。
Expand All @@ -58,6 +61,12 @@ func parseOTLPHTTPEndpoint(endpoint string, insecure bool) (otlpHTTPEndpoint, er
return ep, errors.New("tracing: empty OTLP/HTTP endpoint")
}
if !strings.Contains(raw, "://") {
if strings.Contains(raw, "/") {
// 漏写 scheme 且带路径:整串当 host:port 收下的话 basePath 恒空、
// 路径段无声丢失 ⇒ 显式报错让调用方补 scheme(#112)。错误不回显
// 端点原值——无 scheme 形态也可能内嵌凭据/令牌。
return ep, errors.New("tracing: scheme-less OTLP/HTTP endpoint must be plain \"host:port\" (e.g. \"otel-collector:4318\"); a base path requires an explicit scheme (e.g. \"https://host:4318/api/otel\")")
}
// 无 scheme:host:port 形态,scheme 由 Insecure 决定。
ep.hostPort = raw
return ep, nil
Expand Down Expand Up @@ -175,15 +184,9 @@ func newHTTPExportingProviders(cfg Config, sampler sdktrace.Sampler, res *resour
return nil, nil, fmt.Errorf("tracing: create otlp http metric exporter: %w", err)
}

reader := metric.WithReader(metric.NewPeriodicReader(metricExp, metric.WithInterval(metricExportInterval)))
mp := metric.NewMeterProvider(reader, metric.WithResource(res))

// 与 grpc 分支同一纪律:必须显式指定 provider(无参 runtime.Start() 在
// 调用时刻捕获的全局还是 noop,runtime 指标会静默丢失)。
if err := runtime.Start(runtime.WithMeterProvider(mp)); err != nil {
_ = mp.Shutdown(context.Background())
_ = tp.Shutdown(context.Background())
return nil, nil, fmt.Errorf("tracing: start runtime metrics collector: %w", err)
mp, err := assembleMeterProvider(metricExp, res, tp) // 装配尾段与 grpc 分支共用(#110)
if err != nil {
return nil, nil, err
}
return tp, mp, nil
}
22 changes: 22 additions & 0 deletions otlphttp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ func TestParseOTLPHTTPEndpoint_RejectsQueryAndFragment(t *testing.T) {
}
}

// TestParseOTLPHTTPEndpoint_SchemelessPathRejected 钉住 #112:无 scheme 分支
// 不再静默吞路径段。"collector:4318/api/otel"(漏写 scheme 但带基址路径)此前
// 被整串当 host:port 收下、basePath 恒空,最终导出打 https://collector:4318/
// v1/traces 而 /api/otel 段无声丢失——Init 成功、导出 404 循环,与
// validateGRPCEndpoint「不合规输入报错,不静默断供」的既定纪律相悖。报错须
// 提示补 scheme,且不回显端点原值(无 scheme 形态也可能内嵌凭据)。
func TestParseOTLPHTTPEndpoint_SchemelessPathRejected(t *testing.T) {
for _, endpoint := range []string{
"collector:4318/api/otel", // 基址路径(issue #112 原始样例形态)
"collector/api", // 无端口带路径
"collector:4318/", // 尾斜杠同罪(路径段以 / 计)
} {
_, err := parseOTLPHTTPEndpoint(endpoint, false)
if err == nil {
t.Fatalf("parseOTLPHTTPEndpoint(%q) error = nil, want error(路径段被静默吞进 host:port ⇒ 导出 404 无线索)", endpoint)
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("error should point at explicit scheme, got: %v", err)
}
}
}

// TestOTLPHTTPEndpoint_SignalPaths signal 路径推导:端点指到哪个 signal,
// 另一个按同前缀换名;否则基址后拼标准路径。
func TestOTLPHTTPEndpoint_SignalPaths(t *testing.T) {
Expand Down
Loading