diff --git a/handlers/baseHandler.go b/handlers/baseHandler.go index 26f19bd..053fefe 100644 --- a/handlers/baseHandler.go +++ b/handlers/baseHandler.go @@ -60,8 +60,8 @@ type HandlerRequest[Req any, Resp any] struct { RespSent bool Builder func(status int, rawResp []byte, headers map[string]string) (*Resp, error) // Tracing fields - Span trace.Span - SpanCtx context.Context + Span trace.Span + SpanCtx context.Context } // Tracing methods for HandlerRequest @@ -108,6 +108,11 @@ func (hr *HandlerRequest[Req, Resp]) StartChildSpan(name string, attrs map[strin return tm.StartSpanWithAttributes(hr.SpanCtx, name, attrs) } +// GetParser returns the RequestParser from WebFramework for tracing +func (hr HandlerRequest[Req, Resp]) GetParser() webFramework.RequestParser { + return hr.W.Parser +} + func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( core requestCore.RequestCoreInterface, handler Handler, @@ -125,7 +130,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( w = libContext.InitContextNoAuditTrail(c) } libContext.AddWebLogs(w, params.Title, webFramework.HandlerLogTag) - + // Initialize tracing if enabled var span trace.Span var spanCtx context.Context @@ -134,13 +139,13 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( if spanName == "" { spanName = params.Title } - + tm := libTracing.GetGlobalTracingManager() spanCtx, span = tm.StartSpanWithAttributes(w.Ctx, spanName, map[string]string{ "handler.title": params.Title, "handler.path": params.Path, }) - + // Add handler attributes if span != nil && span.IsRecording() { span.SetAttributes( @@ -151,7 +156,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( ) } } - + trx := HandlerRequest[Req, Resp]{ Title: params.Title, Args: args, @@ -162,7 +167,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } defer Recovery(start, w, handler, params, trx, core) - + // Ensure span is ended defer func() { if span != nil { @@ -185,7 +190,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( trx.Header = header var err error - trx.Response, err = handler.Simulation(trx) + trx.Response, err = libTracing.TraceFunc(handler.Simulation, trx) if err != nil { core.Responder().Error(trx.W, err) return @@ -215,7 +220,8 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( w.Parser.SetLocal("reqLog", nil) } - errInit := handler.Initializer(trx) + var errInit error + errInit = libTracing.TraceError(handler.Initializer, trx) webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("initialize", errInit)) if errInit != nil { core.Responder().Error(trx.W, errInit) @@ -223,7 +229,7 @@ func BaseHandler[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( } var err error - trx.Response, err = handler.Handler(trx) + trx.Response, err = libTracing.TraceFunc(handler.Handler, trx) webFramework.AddLog(w, webFramework.HandlerLogTag, slog.Any("main-handler", err)) if err != nil { core.Responder().Error(trx.W, err) diff --git a/handlers/callApi.go b/handlers/callApi.go index 58192ee..a687b97 100644 --- a/handlers/callApi.go +++ b/handlers/callApi.go @@ -95,8 +95,8 @@ func CallApiJSON[Req any, Resp any]( webFramework.AddLog(w, CallApiLogEntry, slog.Any(method, param)) param.BodyType = libCallApi.JSON - if param.Context == nil { - param.Context = w.Ctx // Set context for distributed tracing if not already set + if param.Parser == nil { + param.Parser = w.Parser // Set parser for distributed tracing if not already set } resp, err := libCallApi.RemoteCall(param) if err != nil { @@ -116,8 +116,8 @@ func CallApiForm[Req any, Resp any]( webFramework.AddLog(w, CallApiLogEntry, slog.Any(method, param)) param.BodyType = libCallApi.Form - if param.Context == nil { - param.Context = w.Ctx // Set context for distributed tracing if not already set + if param.Parser == nil { + param.Parser = w.Parser // Set parser for distributed tracing if not already set } resp, err := libCallApi.RemoteCall(param) if err != nil { diff --git a/handlers/consumeHandler.go b/handlers/consumeHandler.go index 45dded2..72c26e1 100644 --- a/handlers/consumeHandler.go +++ b/handlers/consumeHandler.go @@ -117,7 +117,7 @@ func (c CallArgs[Req, Resp]) Handler(req HandlerRequest[Req, Resp]) (Resp, error Api: *req.Core.Params().GetRemoteApi(c.Api), Method: c.Method, Path: finalPath, - Context: req.W.Ctx, // Pass context for distributed tracing + Parser: req.W.Parser, // Pass parser for distributed tracing }, ) if err != nil { @@ -225,7 +225,7 @@ func (h *ConsumeHandlerType[Req, Resp]) Handler(req HandlerRequest[Req, Resp]) ( EnableLog: false, Headers: headersMap, Builder: req.Builder, - Context: req.W.Ctx, // Pass context for distributed tracing + Parser: req.W.Parser, // Pass parser for distributed tracing }) if errCall != nil { return req.Response, errCall diff --git a/handlers/recovery.go b/handlers/recovery.go index e875e9f..4c9d846 100644 --- a/handlers/recovery.go +++ b/handlers/recovery.go @@ -8,6 +8,7 @@ import ( "github.com/hmmftg/requestCore" "github.com/hmmftg/requestCore/libError" + "github.com/hmmftg/requestCore/libTracing" "github.com/hmmftg/requestCore/response" "github.com/hmmftg/requestCore/status" "github.com/hmmftg/requestCore/webFramework" @@ -23,7 +24,7 @@ func Recovery[Req any, Resp any, Handler HandlerInterface[Req, Resp]]( ) { elapsed := time.Since(start) webFramework.AddLogTag(w, webFramework.HandlerLogTag, slog.String("elapsed", elapsed.String())) - handler.Finalizer(trx) + libTracing.TraceVoid(handler.Finalizer, trx) webFramework.CollectLogTags(w, webFramework.HandlerLogTag) webFramework.CollectLogArrays(w, webFramework.HandlerLogTag) webFramework.CollectLogTags(w, webFramework.ErrorListLogTag) diff --git a/libCallApi/call.go b/libCallApi/call.go index 23a2a1c..1e101b8 100644 --- a/libCallApi/call.go +++ b/libCallApi/call.go @@ -8,6 +8,7 @@ import ( "time" "github.com/hmmftg/requestCore/response" + "github.com/hmmftg/requestCore/webFramework" ) type CallParam *CallParamData @@ -24,7 +25,7 @@ type CallParamData struct { ValidateTls bool EnableLog bool JsonBody any - Context context.Context `json:"-"` // Context for distributed tracing and request cancellation + Parser webFramework.RequestParser `json:"-"` // Parser for distributed tracing and request cancellation } func (r CallParamData) LogValue() slog.Value { @@ -44,24 +45,27 @@ type BuilerFunc[Resp any] func(status int, rawResp []byte, headers map[string]st type RemoteCallParamData[Req, Resp any] struct { HttpClient *http.Client - Parameters map[string]any `json:"-"` - Headers map[string]string `json:"-"` - Api RemoteApi `json:"api"` - Timeout time.Duration `json:"-"` - Method string `json:"method"` - Path string `json:"path"` - Query string `json:"-"` - QueryStack *[]string `json:"-"` - ValidateTls bool `json:"-"` - EnableLog bool `json:"-"` - JsonBody Req `json:"body"` - BodyType RequestBodyType `json:"-"` - Builder BuilerFunc[Resp] `json:"-"` - Context context.Context `json:"-"` // Context for distributed tracing and request cancellation + Parameters map[string]any `json:"-"` + Headers map[string]string `json:"-"` + Api RemoteApi `json:"api"` + Timeout time.Duration `json:"-"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"-"` + QueryStack *[]string `json:"-"` + ValidateTls bool `json:"-"` + EnableLog bool `json:"-"` + JsonBody Req `json:"body"` + BodyType RequestBodyType `json:"-"` + Builder BuilerFunc[Resp] `json:"-"` + Parser webFramework.RequestParser `json:"-"` // Parser for distributed tracing and request cancellation } func (r RemoteCallParamData[Req, Resp]) LogValue() slog.Value { headers := maps.Clone(r.Headers) + if headers == nil { + headers = map[string]string{} + } headers["Authorization"] = "[masked]" return slog.GroupValue( slog.String("api", r.Api.Name), @@ -91,6 +95,13 @@ func Call[RespType any](param CallParam) CallResult[RespType] { *param.QueryStack = nil } } + + // Prepare context for distributed tracing / cancellation + ctx := context.Background() + if param.Parser != nil { + ctx = param.Parser.GetContext() + } + callData := CallData[RespType]{ Api: param.Api, Path: param.Path + param.Query, @@ -100,9 +111,11 @@ func Call[RespType any](param CallParam) CallResult[RespType] { EnableLog: param.EnableLog, Timeout: param.Timeout, Req: param.JsonBody, - Context: param.Context, // Pass context for distributed tracing + Context: ctx, + LogValue: (*CallParamData)(param).LogValue(), httpClient: param.HttpClient, } + resp, wsResp, callResp, err := ConsumeRest(callData) return CallResult[RespType]{resp, wsResp, callResp, err} } @@ -116,6 +129,13 @@ func RemoteCall[Req, Resp any](param *RemoteCallParamData[Req, Resp]) (*Resp, er *param.QueryStack = nil } } + + // Prepare context for distributed tracing / cancellation + ctx := context.Background() + if param.Parser != nil { + ctx = param.Parser.GetContext() + } + callData := CallData[Resp]{ Api: param.Api, Path: param.Path + param.Query, @@ -127,8 +147,10 @@ func RemoteCall[Req, Resp any](param *RemoteCallParamData[Req, Resp]) (*Resp, er Req: param.JsonBody, BodyType: param.BodyType, Builder: param.Builder, - Context: param.Context, // Pass context for distributed tracing + Context: ctx, + LogValue: param.LogValue(), httpClient: param.HttpClient, } + return ConsumeRestJSON(&callData) } diff --git a/libCallApi/callApi.go b/libCallApi/callApi.go index 0ab0568..ca3785d 100644 --- a/libCallApi/callApi.go +++ b/libCallApi/callApi.go @@ -1,5 +1,7 @@ package libCallApi +//lint:file-ignore SA4006 gopls/staticcheck false-positive in this file (span-related diagnostics) + import ( "bytes" "context" @@ -10,6 +12,7 @@ import ( "fmt" "io" "log" + "log/slog" "net/http" "net/http/httptrace" "os" @@ -18,10 +21,13 @@ import ( "github.com/google/go-querystring/query" "github.com/hmmftg/requestCore/libError" + "github.com/hmmftg/requestCore/libTracing" "github.com/hmmftg/requestCore/response" "github.com/hmmftg/requestCore/status" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" ) func (m RemoteApiModel) ConsumeRestBasicAuthApi(requestJson []byte, apiName, path, contentType, method string, headers map[string]string) ([]byte, string, error) { @@ -140,6 +146,8 @@ type CallData[Resp any] struct { LogLevel int Builder func(int, []byte, map[string]string) (*Resp, error) Context context.Context // Context for distributed tracing and request cancellation + // LogValue is optional and used only for tracing attributes (derived from the caller's LogValue()). + LogValue slog.Value } type CallResp struct { @@ -313,8 +321,38 @@ func ConsumeRest[Resp any](c CallData[Resp]) (*Resp, *response.WsRemoteResponse, if c.EnableLog { req = c.SetLogs(req) } - resp, err := cl.Do(req) + + // Distributed tracing / cancellation context + ctx := c.Context + if ctx == nil { + ctx = context.Background() + } + spanName, spanAttrs := libTracing.HTTPClientSpanNameAndAttrs( + c.Api.Name, + c.Api.Domain, + c.Method, + c.Path, + c.Timeout, + c.SslVerify, + ) + for k, v := range libTracing.SpanAttrsFromSlogValue("call", c.LogValue) { + spanAttrs[k] = v + } + + startTime := time.Now() + + // Ensure propagation by running request with the span context. + resp, err, traceCtx := libTracing.TraceFuncWithSpanName(ctx, spanName, spanAttrs, func(spanCtx context.Context) (*http.Response, error) { + return cl.Do(req.WithContext(spanCtx)) + }) if err != nil { + // Record connection/network errors + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + libTracing.RecordError(traceCtx, err, map[string]string{ + "error.type": "http_client_error", + }) + span.SetStatus(codes.Error, "HTTP request failed") + } if os.IsTimeout(err) { return nil, nil, nil, errors.Join(err, libError.NewWithDescription(http.StatusRequestTimeout, "API_CONNECT_TIMED_OUT", "error in ConsumeRest.ClientDo: %s %s", req.Method, req.RequestURI)) } @@ -322,11 +360,35 @@ func ConsumeRest[Resp any](c CallData[Resp]) (*Resp, *response.WsRemoteResponse, } defer resp.Body.Close() + // Add HTTP response attributes to span + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + duration := time.Since(startTime) + libTracing.AddSpanAttributes(traceCtx, map[string]string{ + "http.status_code": fmt.Sprintf("%d", resp.StatusCode), + "http.response.size": fmt.Sprintf("%d", resp.ContentLength), + "http.request.duration": duration.String(), + }) + + // Set span status based on HTTP status code + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + span.SetStatus(codes.Ok, "") + } else if resp.StatusCode >= 400 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", resp.StatusCode)) + } + } + var respJson *Resp var errResp *response.WsRemoteResponse respJson, errResp, callResp, err := GetResp[Resp, response.WsRemoteResponse](c.Api, resp) if err != nil { + // Record parsing/response errors + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + libTracing.RecordError(traceCtx, err, map[string]string{ + "error.type": "response_parsing_error", + "http.status_code": fmt.Sprintf("%d", resp.StatusCode), + }) + } if ok, errPrepare := response.Unwrap(err); ok { return nil, nil, nil, errPrepare.Input(resp) } @@ -365,8 +427,37 @@ func ConsumeRestJSON[Resp any](c *CallData[Resp]) (*Resp, error) { if c.EnableLog { req = c.SetLogs(req) } - resp, err := cl.Do(req) + + // Distributed tracing / cancellation context + ctx := c.Context + if ctx == nil { + ctx = context.Background() + } + spanName, spanAttrs := libTracing.HTTPClientSpanNameAndAttrs( + c.Api.Name, + c.Api.Domain, + c.Method, + c.Path, + c.Timeout, + c.SslVerify, + ) + for k, v := range libTracing.SpanAttrsFromSlogValue("call", c.LogValue) { + spanAttrs[k] = v + } + startTime := time.Now() + + // Ensure propagation by running request with the span context. + resp, err, traceCtx := libTracing.TraceFuncWithSpanName(ctx, spanName, spanAttrs, func(spanCtx context.Context) (*http.Response, error) { + return cl.Do(req.WithContext(spanCtx)) + }) if err != nil { + // Record connection/network errors + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + libTracing.RecordError(traceCtx, err, map[string]string{ + "error.type": "http_client_error", + }) + span.SetStatus(codes.Error, "HTTP request failed") + } if os.IsTimeout(err) { return nil, errors.Join(err, libError.NewWithDescription(http.StatusRequestTimeout, "API_CONNECT_TIMED_OUT", "error in ConsumeRest.ClientDo: %s %s", req.Method, req.RequestURI)) } @@ -374,12 +465,38 @@ func ConsumeRestJSON[Resp any](c *CallData[Resp]) (*Resp, error) { } defer resp.Body.Close() + // Add HTTP response attributes to span + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + duration := time.Since(startTime) + libTracing.AddSpanAttributes(traceCtx, map[string]string{ + "http.status_code": fmt.Sprintf("%d", resp.StatusCode), + "http.response.size": fmt.Sprintf("%d", resp.ContentLength), + "http.request.duration": duration.String(), + }) + + // Set span status based on HTTP status code + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + span.SetStatus(codes.Ok, "") + } else if resp.StatusCode >= 400 && resp.StatusCode < 500 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", resp.StatusCode)) + } else if resp.StatusCode >= 500 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", resp.StatusCode)) + } + } + if c.Builder == nil { c.Builder = DefaultBuilderfunc[Resp] } respJson, err := GetJSONResp(c.Api, resp, c.Builder) if err != nil { + // Record parsing/response errors + if span := trace.SpanFromContext(traceCtx); span.IsRecording() { + libTracing.RecordError(traceCtx, err, map[string]string{ + "error.type": "response_parsing_error", + "http.status_code": fmt.Sprintf("%d", resp.StatusCode), + }) + } if ok, errPrepare := response.Unwrap(err); ok { return nil, errPrepare.Input(c) } diff --git a/libContext/test-context.go b/libContext/test-context.go index 8f70b37..f0a217d 100644 --- a/libContext/test-context.go +++ b/libContext/test-context.go @@ -215,3 +215,13 @@ func (t TestingParser) AddSpanEvent(name string, attrs map[string]string) { func (t TestingParser) RecordSpanError(err error, attrs map[string]string) { // No-op for testing } + +// GetContext returns a background context for testing +func (t TestingParser) GetContext() context.Context { + return context.Background() +} + +// SetContext is a no-op for testing +func (t TestingParser) SetContext(ctx context.Context) { + // No-op for testing +} diff --git a/libFiber/parser.go b/libFiber/parser.go index b39b3ac..279c50b 100644 --- a/libFiber/parser.go +++ b/libFiber/parser.go @@ -234,3 +234,13 @@ func (c FiberParser) RecordSpanError(err error, attrs map[string]string) { span.RecordError(err, trace.WithAttributes(eventAttrs...)) } } + +// GetContext returns the context from the Fiber context +func (c FiberParser) GetContext() context.Context { + return c.Ctx.UserContext() +} + +// SetContext updates the context in the Fiber context +func (c FiberParser) SetContext(ctx context.Context) { + c.Ctx.SetUserContext(ctx) +} \ No newline at end of file diff --git a/libGin/parser.go b/libGin/parser.go index 6b22587..b38fad9 100644 --- a/libGin/parser.go +++ b/libGin/parser.go @@ -224,3 +224,13 @@ func (c GinParser) RecordSpanError(err error, attrs map[string]string) { span.RecordError(err, trace.WithAttributes(eventAttrs...)) } } + +// GetContext returns the context from the Gin request +func (c GinParser) GetContext() context.Context { + return c.Ctx.Request.Context() +} + +// SetContext updates the context in the Gin request +func (c GinParser) SetContext(ctx context.Context) { + c.Ctx.Request = c.Ctx.Request.WithContext(ctx) +} diff --git a/libNetHttp/parser.go b/libNetHttp/parser.go index c123d44..616d55e 100644 --- a/libNetHttp/parser.go +++ b/libNetHttp/parser.go @@ -391,3 +391,13 @@ func (c NetHttpParser) RecordSpanError(err error, attrs map[string]string) { span.RecordError(err, trace.WithAttributes(eventAttrs...)) } } + +// GetContext returns the context from the HTTP request +func (c NetHttpParser) GetContext() context.Context { + return c.Request.Context() +} + +// SetContext updates the context in the HTTP request +func (c NetHttpParser) SetContext(ctx context.Context) { + c.Request = c.Request.WithContext(ctx) +} diff --git a/libTracing/http.go b/libTracing/http.go new file mode 100644 index 0000000..6b4a7f8 --- /dev/null +++ b/libTracing/http.go @@ -0,0 +1,116 @@ +package libTracing + +import ( + "fmt" + "log/slog" + "net/url" + "time" +) + +// HTTPClientSpanNameAndAttrs builds a span name and attributes for outbound HTTP calls. +// domain should be the base domain (e.g. https://service/api), and target is the full target path (path + query). +func HTTPClientSpanNameAndAttrs(apiName, domain, method, target string, timeout time.Duration, sslVerify bool) (string, map[string]string) { + spanName := fmt.Sprintf("%s %s", apiName, method) + if apiName == "" { + spanName = fmt.Sprintf("HTTP %s", method) + } + + fullURL := domain + "/" + target + parsedURL, _ := url.Parse(fullURL) + if parsedURL == nil { + parsedURL, _ = url.Parse("http://" + fullURL) + } + + attrs := map[string]string{ + "http.method": method, + "http.url": fullURL, + "http.target": target, + } + + if parsedURL != nil { + scheme := parsedURL.Scheme + if scheme == "" { + scheme = "http" + } + host := parsedURL.Host + if host == "" { + host = domain + } + attrs["http.scheme"] = scheme + attrs["http.host"] = host + } + + if apiName != "" { + attrs["api.name"] = apiName + } + if timeout > 0 { + attrs["timeout"] = timeout.String() + } + attrs["ssl.verify"] = fmt.Sprintf("%v", sslVerify) + + return spanName, attrs +} + +// MergeSpanAttrs merges extra into base, returning base (creating it if nil). +func MergeSpanAttrs(base, extra map[string]string) map[string]string { + if base == nil && extra == nil { + return map[string]string{} + } + if base == nil { + base = map[string]string{} + } + for k, v := range extra { + base[k] = v + } + return base +} + +// SpanAttrsFromSlogValue flattens a slog.Value (typically from LogValue()) into span attributes. +// It flattens groups as prefix.key and stringifies values. Large values are truncated. +func SpanAttrsFromSlogValue(prefix string, v slog.Value) map[string]string { + if prefix == "" { + prefix = "log" + } + out := map[string]string{} + addSlogValueAttrs(out, prefix, v) + return out +} + +func addSlogValueAttrs(out map[string]string, key string, v slog.Value) { + v = v.Resolve() + + switch v.Kind() { + case slog.KindGroup: + for _, a := range v.Group() { + addSlogValueAttrs(out, key+"."+a.Key, a.Value) + } + case slog.KindString: + out[key] = truncateAttr(v.String()) + case slog.KindBool: + out[key] = fmt.Sprintf("%v", v.Bool()) + case slog.KindDuration: + out[key] = v.Duration().String() + case slog.KindTime: + out[key] = v.Time().Format(time.RFC3339Nano) + case slog.KindInt64: + out[key] = fmt.Sprintf("%d", v.Int64()) + case slog.KindUint64: + out[key] = fmt.Sprintf("%d", v.Uint64()) + case slog.KindFloat64: + out[key] = fmt.Sprintf("%v", v.Float64()) + case slog.KindAny: + out[key] = truncateAttr(fmt.Sprint(v.Any())) + default: + out[key] = truncateAttr(v.String()) + } +} + +func truncateAttr(s string) string { + const max = 1024 + if len(s) <= max { + return s + } + return s[:max] + "…" +} + + diff --git a/libTracing/instrumentation.go b/libTracing/instrumentation.go new file mode 100644 index 0000000..e83f270 --- /dev/null +++ b/libTracing/instrumentation.go @@ -0,0 +1,660 @@ +package libTracing + +import ( + "context" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/hmmftg/requestCore/webFramework" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// TracableArg constraint for types that have RequestParser (like HandlerRequest) +type TracableArg interface { + GetParser() webFramework.RequestParser +} + +// getCallerInfo extracts function name and file info from the call stack +// skip is the number of stack frames to skip (0 = caller of getCallerInfo) +func getCallerInfo(skip int) (funcName, spanName string) { + pc, file, line, ok := runtime.Caller(skip + 1) + if !ok { + return "unknown", "unknown" + } + + // Get function name + fn := runtime.FuncForPC(pc) + if fn != nil { + fullName := fn.Name() + // Extract just the function name (last part after last dot) + parts := strings.Split(fullName, ".") + funcName = parts[len(parts)-1] + + // Generate span name from function name (convert CamelCase to lowercase with dots) + spanName = toSnakeCase(funcName) + } + + // Add file info for debugging + _, fileName := filepath.Split(file) + _ = fileName + _ = line + + return funcName, spanName +} + +// toSnakeCase converts CamelCase to snake_case for span names +func toSnakeCase(s string) string { + if s == "" { + return "unknown" + } + + var result strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result.WriteByte('_') + } + result.WriteRune(r) + } + return strings.ToLower(result.String()) +} + +// isTracingEnabled checks if tracing is enabled for the given context +func isTracingEnabled(ctx context.Context) bool { + if ctx == nil { + return false + } + span := trace.SpanFromContext(ctx) + return span != nil && span.IsRecording() +} + +// traceWithSpan is the internal helper that handles span creation and execution +func traceWithSpan(ctx context.Context, spanName string, fn func(context.Context) (context.Context, error)) (context.Context, error) { + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + newCtx, err := fn(ctx) + return newCtx, err + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + newCtx, err := fn(ctx) + return newCtx, err + } + + // Start span with auto-generated attributes + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + newCtx, err := fn(ctx) + return newCtx, err + } + + // Measure execution time + start := time.Now() + + // Execute function with span context + newCtx, err := fn(spanCtx) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally, don't break execution) + if err != nil { + // Record error in span + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + // Set success status + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + return newCtx, err +} + +// TraceFunc traces a function that returns (T, error) - Normal mode (automatic parser extraction) +// Usage: result, err := TraceFunc(handler.Handler, trx) +// Automatically extracts context from arg's RequestParser and updates it +func TraceFunc[T any, Arg TracableArg](fn func(Arg) (T, error), arg Arg) (T, error) { + parser := arg.GetParser() + return traceFuncWithParser(parser, fn, arg) +} + +// TraceFuncWithParser traces a function that returns (T, error) - Manual mode (explicit parser) +// Usage: result, err := TraceFuncWithParser(parser, myFunction, myArg) +// Uses provided parser to extract and update context +func TraceFuncWithParser[T any, Arg any](parser webFramework.RequestParser, fn func(Arg) (T, error), arg Arg) (T, error) { + return traceFuncWithParser(parser, fn, arg) +} + +// traceFuncWithParser is the internal implementation shared by both modes +func traceFuncWithParser[T any, Arg any](parser webFramework.RequestParser, fn func(Arg) (T, error), arg Arg) (T, error) { + // Get context from parser + ctx := parser.GetContext() + + // Auto-detect span name from caller + _, spanName := getCallerInfo(2) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + return fn(arg) + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + return fn(arg) + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + return fn(arg) + } + + // Measure execution time + start := time.Now() + + // Execute function + result, err := fn(arg) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally) + if err != nil { + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + // Update context in parser + parser.SetContext(spanCtx) + + return result, err +} + +// TraceError traces a function that returns only error - Normal mode (automatic parser extraction) +// Usage: err := TraceError(handler.Initializer, trx) +// Automatically extracts context from arg's RequestParser and updates it +func TraceError[Arg TracableArg](fn func(Arg) error, arg Arg) error { + parser := arg.GetParser() + return traceErrorWithParser(parser, fn, arg) +} + +// TraceErrorWithParser traces a function that returns only error - Manual mode (explicit parser) +// Usage: err := TraceErrorWithParser(parser, myFunction, myArg) +// Uses provided parser to extract and update context +func TraceErrorWithParser[Arg any](parser webFramework.RequestParser, fn func(Arg) error, arg Arg) error { + return traceErrorWithParser(parser, fn, arg) +} + +// traceErrorWithParser is the internal implementation shared by both modes +func traceErrorWithParser[Arg any](parser webFramework.RequestParser, fn func(Arg) error, arg Arg) error { + // Get context from parser + ctx := parser.GetContext() + + // Auto-detect span name from caller + _, spanName := getCallerInfo(2) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + return fn(arg) + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + return fn(arg) + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + return fn(arg) + } + + // Measure execution time + start := time.Now() + + // Execute function + err := fn(arg) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally) + if err != nil { + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + // Update context in parser + parser.SetContext(spanCtx) + + return err +} + +// TraceVoid traces a function with no return value - Normal mode (automatic parser extraction) +// Usage: TraceVoid(handler.Finalizer, trx) +// Automatically extracts context from arg's RequestParser and updates it +func TraceVoid[Arg TracableArg](fn func(Arg), arg Arg) { + parser := arg.GetParser() + traceVoidWithParser(parser, fn, arg) +} + +// TraceVoidWithParser traces a function with no return value - Manual mode (explicit parser) +// Usage: TraceVoidWithParser(parser, myFunction, myArg) +// Uses provided parser to extract and update context +func TraceVoidWithParser[Arg any](parser webFramework.RequestParser, fn func(Arg), arg Arg) { + traceVoidWithParser(parser, fn, arg) +} + +// traceVoidWithParser is the internal implementation shared by both modes +func traceVoidWithParser[Arg any](parser webFramework.RequestParser, fn func(Arg), arg Arg) { + // Get context from parser + ctx := parser.GetContext() + + // Auto-detect span name from caller + _, spanName := getCallerInfo(2) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + fn(arg) + return + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + fn(arg) + return + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + fn(arg) + return + } + + // Measure execution time + start := time.Now() + + // Execute function + fn(arg) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + span.SetStatus(codes.Ok, "") + } + + // End span + span.End() + + // Update context in parser + parser.SetContext(spanCtx) +} + +// TraceFuncWithContext traces a function that takes context and returns (T, error) +// Usage: result, err, newCtx := TraceFuncWithContext(ctx, func(ctx context.Context) (ResultType, error) { ... }) +// Returns: (result, error, newContext) - newContext contains the span context for propagation +func TraceFuncWithContext[T any](ctx context.Context, fn func(context.Context) (T, error)) (T, error, context.Context) { + // Auto-detect span name from caller + _, spanName := getCallerInfo(1) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + result, err := fn(ctx) + return result, err, ctx + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + result, err := fn(ctx) + return result, err, ctx + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + result, err := fn(ctx) + return result, err, ctx + } + + // Measure execution time + start := time.Now() + + // Execute function with span context + result, err := fn(spanCtx) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally) + if err != nil { + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + return result, err, spanCtx +} + +// TraceErrorWithContext traces a function that takes context and returns error +// Usage: err, newCtx := TraceErrorWithContext(ctx, func(ctx context.Context) error { ... }) +// Returns: (error, newContext) - newContext contains the span context for propagation +func TraceErrorWithContext(ctx context.Context, fn func(context.Context) error) (error, context.Context) { + // Auto-detect span name from caller + _, spanName := getCallerInfo(1) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + err := fn(ctx) + return err, ctx + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + err := fn(ctx) + return err, ctx + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + err := fn(ctx) + return err, ctx + } + + // Measure execution time + start := time.Now() + + // Execute function with span context + err := fn(spanCtx) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally) + if err != nil { + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + return err, spanCtx +} + +// TraceVoidWithContext traces a function that takes context and returns nothing +// Usage: newCtx := TraceVoidWithContext(ctx, func(ctx context.Context) { ... }) +// Returns: newContext - contains the span context for propagation +func TraceVoidWithContext(ctx context.Context, fn func(context.Context)) context.Context { + // Auto-detect span name from caller + _, spanName := getCallerInfo(1) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + fn(ctx) + return ctx + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + fn(ctx) + return ctx + } + + // Start span + attrs := map[string]string{ + "function.name": spanName, + } + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, attrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + fn(ctx) + return ctx + } + + // Measure execution time + start := time.Now() + + // Execute function with span context + fn(spanCtx) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + span.SetStatus(codes.Ok, "") + } + + // End span + span.End() + + return spanCtx +} + +// TraceFuncWithSpanName traces a function that takes context and returns (T, error) with custom span name and attributes +// Usage: result, err, newCtx := TraceFuncWithSpanName(ctx, spanName, attrs, func(ctx context.Context) (ResultType, error) { ... }) +func TraceFuncWithSpanName[T any](ctx context.Context, spanName string, attrs map[string]string, fn func(context.Context) (T, error)) (T, error, context.Context) { + // Fast path: if tracing is not enabled, execute function without tracing + if !isTracingEnabled(ctx) { + result, err := fn(ctx) + return result, err, ctx + } + + // Get tracing manager + tm := GetGlobalTracingManager() + if tm == nil { + // Tracing manager not available, execute function normally + result, err := fn(ctx) + return result, err, ctx + } + + // Use provided span name or auto-detect + if spanName == "" { + _, spanName = getCallerInfo(1) + if spanName == "" || spanName == "unknown" { + spanName = "function" + } + } + + // Merge provided attributes with default + spanAttrs := make(map[string]string) + for k, v := range attrs { + spanAttrs[k] = v + } + spanAttrs["function.name"] = spanName + + spanCtx, span := tm.StartSpanWithAttributes(ctx, spanName, spanAttrs) + if span == nil { + // Span creation failed, execute function normally (zero-error design) + result, err := fn(ctx) + return result, err, ctx + } + + // Measure execution time + start := time.Now() + + // Execute function with span context + result, err := fn(spanCtx) + + // Record duration + duration := time.Since(start) + if span.IsRecording() { + span.SetAttributes( + attribute.Int64("function.duration_ms", duration.Milliseconds()), + attribute.Int64("function.duration_ns", duration.Nanoseconds()), + ) + } + + // Record error if any (zero-error: handle internally) + if err != nil { + if span.IsRecording() { + tm.RecordError(spanCtx, err, map[string]string{ + "error.type": "function_error", + }) + span.SetStatus(codes.Error, err.Error()) + } + } else { + if span.IsRecording() { + span.SetStatus(codes.Ok, "") + } + } + + // End span + span.End() + + return result, err, spanCtx +} diff --git a/webFramework/fakeParser.go b/webFramework/fakeParser.go index 729509a..2c36bc3 100644 --- a/webFramework/fakeParser.go +++ b/webFramework/fakeParser.go @@ -177,3 +177,13 @@ func (c FakeParser) AddSpanEvent(name string, attrs map[string]string) { func (c FakeParser) RecordSpanError(err error, attrs map[string]string) { // No-op for testing } + +// GetContext returns a background context for testing +func (c FakeParser) GetContext() context.Context { + return context.Background() +} + +// SetContext is a no-op for testing +func (c FakeParser) SetContext(ctx context.Context) { + // No-op for testing +} \ No newline at end of file diff --git a/webFramework/model.go b/webFramework/model.go index 2844c0f..3631267 100644 --- a/webFramework/model.go +++ b/webFramework/model.go @@ -71,6 +71,9 @@ type RequestParser interface { AddSpanAttributes(attrs map[string]string) AddSpanEvent(name string, attrs map[string]string) RecordSpanError(err error, attrs map[string]string) + // Context management for tracing + GetContext() context.Context + SetContext(context.Context) } type RequestHandler interface {