diff --git a/contrib/go.uber.org/zap/core.go b/contrib/go.uber.org/zap/core.go new file mode 100644 index 00000000000..8dbb8074dff --- /dev/null +++ b/contrib/go.uber.org/zap/core.go @@ -0,0 +1,59 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package zap + +import ( + "go.uber.org/zap/zapcore" +) + +var _ zapcore.Core = (*traceCore)(nil) + +// traceCore is a zapcore.Core wrapper that can be used for trace context injection. +// Note: Automatic trace context injection via GLS is not currently supported for zap +// because zap's Write method does not receive a context.Context. +// Use WithTraceFields(ctx, logger) for manual trace context injection. +type traceCore struct { + zapcore.Core +} + +// WrapCore wraps a zapcore.Core. This is a hook for potential future enhancements. +// Currently, it simply returns a wrapped core that passes through to the underlying core. +func WrapCore(core zapcore.Core) zapcore.Core { + if _, ok := core.(*traceCore); ok { + return core // Already wrapped + } + return &traceCore{Core: core} +} + +// IsAlreadyWrapped checks whether the given core is already wrapped by this package. +func IsAlreadyWrapped(core zapcore.Core) bool { + _, ok := core.(*traceCore) + return ok +} + +// With creates a new core with additional fields. +func (c *traceCore) With(fields []zapcore.Field) zapcore.Core { + return &traceCore{Core: c.Core.With(fields)} +} + +// Check determines whether the supplied Entry should be logged. +func (c *traceCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Core.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +// Write serializes the Entry and any Fields supplied at the log site and +// writes them to their destination. +func (c *traceCore) Write(ent zapcore.Entry, fields []zapcore.Field) error { + return c.Core.Write(ent, fields) +} + +// Sync flushes buffered logs (if any). +func (c *traceCore) Sync() error { + return c.Core.Sync() +} diff --git a/contrib/go.uber.org/zap/example_test.go b/contrib/go.uber.org/zap/example_test.go new file mode 100644 index 00000000000..7e63a1a5bae --- /dev/null +++ b/contrib/go.uber.org/zap/example_test.go @@ -0,0 +1,31 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package zap_test + +import ( + "context" + + "go.uber.org/zap" + + zaptrace "github.com/DataDog/dd-trace-go/v2/contrib/go.uber.org/zap" + "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" +) + +func ExampleWithTraceFields() { + // start the DataDog tracer + tracer.Start() + defer tracer.Stop() + + // create the application logger + logger, _ := zap.NewProduction() + + // start a new span + span, ctx := tracer.StartSpanFromContext(context.Background(), "ExampleWithTraceCorrelation") + defer span.Finish() + + // log a message using the context containing span information + zaptrace.WithTraceFields(ctx, logger).Info("this is a log with tracing information") +} diff --git a/contrib/go.uber.org/zap/orchestrion.yml b/contrib/go.uber.org/zap/orchestrion.yml new file mode 100644 index 00000000000..4bcbd03a2f8 --- /dev/null +++ b/contrib/go.uber.org/zap/orchestrion.yml @@ -0,0 +1,35 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2023-present Datadog, Inc. +--- +# yaml-language-server: $schema=https://datadoghq.dev/orchestrion/schema.json +meta: + name: github.com/DataDog/dd-trace-go/v2/contrib/go.uber.org/zap + description: |- + Blazing fast, structured, leveled logging in Go. This integration wraps zap + constructors for potential future enhancements. Note: Automatic trace context + injection is not currently supported because zap's Write method does not receive + a context. Use WithTraceFields(ctx, logger) for manual trace injection. + +aspects: + # Wrap zap.New to inject trace-aware core + - id: New + join-point: + all-of: + - import-path: go.uber.org/zap + - function-body: + function: + - name: New + advice: + - inject-declarations: + imports: + zapcore: go.uber.org/zap/zapcore + links: + - github.com/DataDog/dd-trace-go/v2/contrib/go.uber.org/zap + template: |- + //go:linkname __dd_zaptrace_WrapCore github.com/DataDog/dd-trace-go/v2/contrib/go.uber.org/zap.WrapCore + func __dd_zaptrace_WrapCore(zapcore.Core) zapcore.Core + - prepend-statements: + template: |- + {{ .Function.Argument 0 }} = __dd_zaptrace_WrapCore({{ .Function.Argument 0 }}) diff --git a/contrib/go.uber.org/zap/zap.go b/contrib/go.uber.org/zap/zap.go new file mode 100644 index 00000000000..917f6e3274b --- /dev/null +++ b/contrib/go.uber.org/zap/zap.go @@ -0,0 +1,60 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +// Package zap provides functions to correlate logs and traces using go.uber.org/zap package (https://github.com/uber-go/zap). +package zap // import "github.com/DataDog/dd-trace-go/v2/contrib/go.uber.org/zap" + +import ( + "context" + "strconv" + + "github.com/DataDog/dd-trace-go/v2/ddtrace/ext" + "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" + "github.com/DataDog/dd-trace-go/v2/instrumentation" + "github.com/DataDog/dd-trace-go/v2/instrumentation/options" + + "go.uber.org/zap" +) + +var instr *instrumentation.Instrumentation + +type config struct { + log128bits bool +} + +var cfg = newConfig() + +func newConfig() *config { + return &config{ + log128bits: options.GetBoolEnv("DD_TRACE_128_BIT_TRACEID_LOGGING_ENABLED", true), + } +} + +func init() { + instr = instrumentation.Load(instrumentation.PackageUberZap) +} + +// WithTraceFields looks for an active span in the provided context and if found, it returns a new *zap.Logger with +// traceID and spanID keys set. +func WithTraceFields(ctx context.Context, logger *zap.Logger) *zap.Logger { + span, ok := tracer.SpanFromContext(ctx) + if !ok { + return logger + } + + var traceID string + if cfg.log128bits && span.Context().TraceID() != tracer.TraceIDZero { + traceID = span.Context().TraceID() + } else { + traceID = strconv.FormatUint(span.Context().TraceIDLower(), 10) + } + + spanID := strconv.FormatUint(span.Context().SpanID(), 10) + + return logger.With( + zap.String(ext.LogKeyTraceID, traceID), + zap.String(ext.LogKeySpanID, spanID), + ) +} diff --git a/contrib/go.uber.org/zap/zap_test.go b/contrib/go.uber.org/zap/zap_test.go new file mode 100644 index 00000000000..41a454cab6f --- /dev/null +++ b/contrib/go.uber.org/zap/zap_test.go @@ -0,0 +1,86 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016 Datadog, Inc. + +package zap + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" + "github.com/DataDog/dd-trace-go/v2/instrumentation/testutils" +) + +func TestWithTraceFields(t *testing.T) { + tracer.Start( + tracer.WithLogger(testutils.DiscardLogger()), + ) + defer tracer.Stop() + + // start a new span + span, ctx := tracer.StartSpanFromContext(context.Background(), "test") + defer span.Finish() + + observed, logs := observer.New(zapcore.InfoLevel) + + logger := zap.New(observed) + logger = WithTraceFields(ctx, logger) + logger.Info("some message") + + // By default, 128-bit trace IDs are enabled + traceID := span.Context().TraceID() + spanID := strconv.FormatUint(span.Context().SpanID(), 10) + + require.Equal(t, 1, logs.Len()) + infoLog := logs.All()[0] + + require.Len(t, infoLog.Context, 2) + assert.Equal(t, "dd.trace_id", infoLog.Context[0].Key) + assert.Equal(t, traceID, infoLog.Context[0].String) + assert.Equal(t, "dd.span_id", infoLog.Context[1].Key) + assert.Equal(t, spanID, infoLog.Context[1].String) +} + +func TestWithTraceFields128BitDisabled(t *testing.T) { + t.Setenv("DD_TRACE_128_BIT_TRACEID_LOGGING_ENABLED", "false") + + // Re-initialize to account for race condition between setting env var in the test and reading it in the contrib + cfg = newConfig() + + tracer.Start( + tracer.WithLogger(testutils.DiscardLogger()), + ) + defer tracer.Stop() + + // start a new span + span, ctx := tracer.StartSpanFromContext(context.Background(), "test") + defer span.Finish() + + observed, logs := observer.New(zapcore.InfoLevel) + + logger := zap.New(observed) + logger = WithTraceFields(ctx, logger) + logger.Info("some message") + + // With 128-bit disabled, should use 64-bit trace ID + traceID := strconv.FormatUint(span.Context().TraceIDLower(), 10) + spanID := strconv.FormatUint(span.Context().SpanID(), 10) + + require.Equal(t, 1, logs.Len()) + infoLog := logs.All()[0] + + require.Len(t, infoLog.Context, 2) + assert.Equal(t, "dd.trace_id", infoLog.Context[0].Key) + assert.Equal(t, traceID, infoLog.Context[0].String) + assert.Equal(t, "dd.span_id", infoLog.Context[1].Key) + assert.Equal(t, spanID, infoLog.Context[1].String) +} diff --git a/ddtrace/tracer/option.go b/ddtrace/tracer/option.go index b48eb0dee28..b9e2fe80bf3 100644 --- a/ddtrace/tracer/option.go +++ b/ddtrace/tracer/option.go @@ -114,6 +114,7 @@ var contribIntegrations = map[string]struct { "github.com/uptrace/bun": {"Bun", false}, "github.com/urfave/negroni": {"Negroni", false}, "github.com/valyala/fasthttp": {"FastHTTP", false}, + "go.uber.org/zap": {"go.uber.org/zap", false}, "github.com/valkey-io/valkey-go": {"Valkey", false}, } diff --git a/ddtrace/tracer/option_test.go b/ddtrace/tracer/option_test.go index bbeeda0dc6a..2c9949b27b0 100644 --- a/ddtrace/tracer/option_test.go +++ b/ddtrace/tracer/option_test.go @@ -295,7 +295,7 @@ func TestAgentIntegration(t *testing.T) { defer clearIntegrationsForTests() cfg.loadContribIntegrations(nil) - assert.Equal(t, 55, len(cfg.integrations)) + assert.Equal(t, 56, len(cfg.integrations)) for integrationName, v := range cfg.integrations { assert.False(t, v.Instrumented, "integrationName=%s", integrationName) } diff --git a/go.mod b/go.mod index 5398eccbbbe..abc75e24c4e 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.27.0 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/mod v0.29.0 @@ -99,7 +100,6 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/text v0.30.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/instrumentation/instrumentation_test.go b/instrumentation/instrumentation_test.go index b1812b6d9b6..97dc32702b0 100644 --- a/instrumentation/instrumentation_test.go +++ b/instrumentation/instrumentation_test.go @@ -21,6 +21,11 @@ func TestInstrumentation_AnalyticsRate(t *testing.T) { t.Skip("Lambda contrib does not implement analytics functionality") return } + // Skip logging integrations - they don't generate spans, just correlate logs with traces + if pkg == PackageSirupsenLogrus || pkg == PackageLogSlog || pkg == PackageUberZap { + t.Skip("Logging integrations do not implement analytics functionality") + return + } instr := Load(pkg) diff --git a/instrumentation/packages.go b/instrumentation/packages.go index 1626978b935..220011a2927 100644 --- a/instrumentation/packages.go +++ b/instrumentation/packages.go @@ -74,6 +74,7 @@ const ( PackageUptraceBun Package = "uptrace/bun" PackageLogSlog Package = "log/slog" PackageModelContextProtocolGoSDK Package = "modelcontextprotocol/go-sdk" + PackageUberZap Package = "go.uber.org/zap" PackageValkeyIoValkeyGo Package = "valkey-io/valkey-go" PackageEnvoyProxyGoControlPlane Package = "envoyproxy/go-control-plane" @@ -815,6 +816,10 @@ var packages = map[Package]PackageInfo{ TracedPackage: "log/slog", IsStdLib: true, }, + PackageUberZap: { + TracedPackage: "go.uber.org/zap", + EnvVarPrefix: "ZAP", + }, PackageValkeyIoValkeyGo: { TracedPackage: "github.com/valkey-io/valkey-go", EnvVarPrefix: "VALKEY", diff --git a/internal/env/supported_configurations.gen.go b/internal/env/supported_configurations.gen.go index 5e5a802da09..fc0e5b754a5 100644 --- a/internal/env/supported_configurations.gen.go +++ b/internal/env/supported_configurations.gen.go @@ -225,6 +225,7 @@ var SupportedConfigurations = map[string]struct{}{ "DD_TRACE_VALKEY_RAW_COMMAND": {}, "DD_TRACE_VAULT_ANALYTICS_ENABLED": {}, "DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH": {}, + "DD_TRACE_ZAP_ANALYTICS_ENABLED": {}, "DD_TRACE__ANALYTICS_ENABLED": {}, "DD_VERSION": {}, "OTEL_EXPORTER_OTLP_ENDPOINT": {}, diff --git a/internal/env/supported_configurations.json b/internal/env/supported_configurations.json index c53a69ab359..576a7c813ae 100644 --- a/internal/env/supported_configurations.json +++ b/internal/env/supported_configurations.json @@ -648,6 +648,9 @@ "DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH": [ "A" ], + "DD_TRACE_ZAP_ANALYTICS_ENABLED": [ + "A" + ], "DD_TRACE__ANALYTICS_ENABLED": [ "A" ], diff --git a/internal/orchestrion/_integration/zap/generated_test.go b/internal/orchestrion/_integration/zap/generated_test.go new file mode 100644 index 00000000000..99d2331dd77 --- /dev/null +++ b/internal/orchestrion/_integration/zap/generated_test.go @@ -0,0 +1,18 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-present Datadog, Inc. +// +// Code generated by 'go generate'; DO NOT EDIT. + +package zap + +import ( + "testing" + + "github.com/DataDog/dd-trace-go/v2/internal/orchestrion/_integration/internal/harness" +) + +func Test(t *testing.T) { + harness.Run(t, new(TestCase)) +} diff --git a/internal/orchestrion/_integration/zap/zap.go b/internal/orchestrion/_integration/zap/zap.go new file mode 100644 index 00000000000..b9c995d6ed4 --- /dev/null +++ b/internal/orchestrion/_integration/zap/zap.go @@ -0,0 +1,127 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-present Datadog, Inc. + +package zap + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/DataDog/dd-trace-go/v2/internal/orchestrion" + "github.com/DataDog/dd-trace-go/v2/internal/orchestrion/_integration/internal/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// orchestrionEnabled returns whether orchestrion is enabled +func orchestrionEnabled() bool { + return orchestrion.Enabled() +} + +type TestCase struct { + logger *zap.Logger + logs *bytes.Buffer +} + +func (tc *TestCase) Setup(_ context.Context, t *testing.T) { + tc.logs = new(bytes.Buffer) + + // Debug: Check if orchestrion is enabled + t.Logf("orchestrion.Enabled() = %v", orchestrionEnabled()) + + // Create a custom encoder config for readable test output + encoderCfg := zapcore.EncoderConfig{ + MessageKey: "msg", + LevelKey: "level", + TimeKey: "time", + NameKey: "logger", + CallerKey: "caller", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(encoderCfg), + zapcore.AddSync(tc.logs), + zapcore.DebugLevel, + ) + + // zap.New is instrumented by orchestrion to wrap the core + tc.logger = zap.New(core) + + // Debug: Log the core type to verify wrapping happened + t.Logf("Created logger with core type: %T", core) +} + +//dd:span +func Log(ctx context.Context, logger *zap.Logger, level zapcore.Level, msg string) { + _ = ctx // ctx is used by orchestrion to inject trace context via GLS + switch level { + case zapcore.DebugLevel: + logger.Debug(msg) + case zapcore.InfoLevel: + logger.Info(msg) + case zapcore.WarnLevel: + logger.Warn(msg) + case zapcore.ErrorLevel: + logger.Error(msg) + } +} + +func (tc *TestCase) Run(ctx context.Context, t *testing.T) { + Log(ctx, tc.logger, zapcore.DebugLevel, "debug") + Log(ctx, tc.logger, zapcore.InfoLevel, "info") + Log(ctx, tc.logger, zapcore.WarnLevel, "warn") + Log(ctx, tc.logger, zapcore.ErrorLevel, "error") + + // Sync to flush any buffered log entries + _ = tc.logger.Sync() + + logs := tc.logs.String() + t.Logf("got logs: %s", logs) + + // Verify all log messages are present + for _, msg := range []string{"debug", "info", "warn", "error"} { + want := `"msg":"` + msg + `"` + if !strings.Contains(logs, want) { + t.Fatalf("missing log message %s", msg) + } + } + + // Note: Automatic trace context injection is not currently supported for zap + // because zap's Write method does not receive a context.Context. + // Use WithTraceFields(ctx, logger) for manual trace injection. +} + +func (*TestCase) ExpectedTraces() trace.Traces { + return trace.Traces{ + { + Tags: map[string]any{ + "name": "Log", + }, + }, + { + Tags: map[string]any{ + "name": "Log", + }, + }, + { + Tags: map[string]any{ + "name": "Log", + }, + }, + { + Tags: map[string]any{ + "name": "Log", + }, + }, + } +} diff --git a/internal/orchestrion/context.go b/internal/orchestrion/context.go index f66d12c5761..baf2ddf9771 100644 --- a/internal/orchestrion/context.go +++ b/internal/orchestrion/context.go @@ -41,6 +41,17 @@ func CtxWithValue(parent context.Context, key, val any) context.Context { return context.WithValue(WrapContext(parent), key, val) } +// GLSPeekValue returns the value from the GLS slot of orchestrion without removing it. +// This is useful for reading the current active span without modifying the stack, +// such as when injecting trace context into log entries. +func GLSPeekValue(key any) any { + if !Enabled() { + return nil + } + + return getDDContextStack().Peek(key) +} + // GLSPopValue pops the value from the GLS slot of orchestrion and returns it. Using context.Context values usually does // not require to pop any stack because the copy of each previous context makes the local variable in the scope disappear // when the current function ends. But the GLS is a semi-global variable that can be accessed from any function in the