From 18f28ed079da17a5158d6554a6245f1cb9c4cc52 Mon Sep 17 00:00:00 2001 From: anzheyazzz Date: Tue, 13 Jan 2026 21:58:12 +0000 Subject: [PATCH 1/4] feat: adding structured logging helper --- lambdacontext/context.go | 8 + lambdacontext/logger.go | 129 ++++++++++++ lambdacontext/logger_test.go | 375 +++++++++++++++++++++++++++++++++++ 3 files changed, 512 insertions(+) create mode 100644 lambdacontext/logger.go create mode 100644 lambdacontext/logger_test.go diff --git a/lambdacontext/context.go b/lambdacontext/context.go index d75d8282..78091bae 100644 --- a/lambdacontext/context.go +++ b/lambdacontext/context.go @@ -15,6 +15,12 @@ import ( "strconv" ) +// LogFormatName is the name of the Log Format, either TEXT or JSON +var LogFormatName string + +// LogLevelName is the name of the Log Levels for structured logging. Only available when LogFormatName is JSON +var LogLevelName string + // LogGroupName is the name of the log group that contains the log streams of the current Lambda Function var LogGroupName string @@ -33,6 +39,8 @@ var FunctionVersion string var maxConcurrency int func init() { + LogFormatName = os.Getenv("AWS_LAMBDA_LOG_FORMAT") + LogLevelName = os.Getenv("AWS_LAMBDA_LOG_LEVEL") LogGroupName = os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME") LogStreamName = os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME") FunctionName = os.Getenv("AWS_LAMBDA_FUNCTION_NAME") diff --git a/lambdacontext/logger.go b/lambdacontext/logger.go new file mode 100644 index 00000000..961bc465 --- /dev/null +++ b/lambdacontext/logger.go @@ -0,0 +1,129 @@ +//go:build go1.21 +// +build go1.21 + +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +package lambdacontext + +import ( + "context" + "log/slog" + "os" +) + +// Field represents an optional field to include in log records. +type Field struct { + key string + value func(*LambdaContext) string +} + +// FunctionArn includes the invoked function ARN in log records. +var FunctionArn = Field{"functionArn", func(lc *LambdaContext) string { return lc.InvokedFunctionArn }} //nolint: staticcheck + +// TenantId includes the tenant ID in log records (for multi-tenant functions). +var TenantId = Field{"tenantId", func(lc *LambdaContext) string { return lc.TenantID }} //nolint: staticcheck + +// Handler returns a [slog.Handler] for AWS Lambda structured logging. +// It reads AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL from environment, +// and injects requestId from Lambda context into each log record. +// +// By default, only requestId is injected. Pass optional fields to include more: +// +// // Default: only requestId +// slog.SetDefault(slog.New(lambdacontext.Handler())) +// +// // With functionArn and tenantId +// slog.SetDefault(slog.New(lambdacontext.Handler(lambdacontext.FunctionArn, lambdacontext.TenantId))) +func Handler(fields ...Field) slog.Handler { + level := parseLogLevel() + opts := &slog.HandlerOptions{ + Level: level, + ReplaceAttr: ReplaceAttr, + } + + var h slog.Handler + if LogFormatName == "JSON" { + h = slog.NewJSONHandler(os.Stdout, opts) + } else { + h = slog.NewTextHandler(os.Stdout, opts) + } + + return &lambdaHandler{handler: h, fields: fields} +} + +// ReplaceAttr maps slog's default keys to AWS Lambda's log format (time->timestamp, msg->message). +func ReplaceAttr(groups []string, attr slog.Attr) slog.Attr { + if len(groups) > 0 { + return attr + } + + switch attr.Key { + case slog.TimeKey: + attr.Key = "timestamp" + case slog.MessageKey: + attr.Key = "message" + } + return attr +} + +// Attrs returns Lambda context fields as slog-compatible key-value pairs. +// For most use cases, using [Handler] with slog.InfoContext is preferred. +func (lc *LambdaContext) Attrs() []any { + return []any{"requestId", lc.AwsRequestID} +} + +// lambdaHandler wraps a slog.Handler to inject Lambda context fields. +type lambdaHandler struct { + handler slog.Handler + fields []Field +} + +// Enabled implements slog.Handler. +func (h *lambdaHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +// Handle implements slog.Handler. +func (h *lambdaHandler) Handle(ctx context.Context, r slog.Record) error { + if lc, ok := FromContext(ctx); ok { + r.AddAttrs(slog.String("requestId", lc.AwsRequestID)) + + for _, f := range h.fields { + if v := f.value(lc); v != "" { + r.AddAttrs(slog.String(f.key, v)) + } + } + } + return h.handler.Handle(ctx, r) +} + +// WithAttrs implements slog.Handler. +func (h *lambdaHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &lambdaHandler{ + handler: h.handler.WithAttrs(attrs), + fields: h.fields, + } +} + +// WithGroup implements slog.Handler. +func (h *lambdaHandler) WithGroup(name string) slog.Handler { + return &lambdaHandler{ + handler: h.handler.WithGroup(name), + fields: h.fields, + } +} + +func parseLogLevel() slog.Level { + switch LogLevelName { + case "DEBUG": + return slog.LevelDebug + case "INFO": + return slog.LevelInfo + case "WARN": + return slog.LevelWarn + case "ERROR": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/lambdacontext/logger_test.go b/lambdacontext/logger_test.go new file mode 100644 index 00000000..a910ab10 --- /dev/null +++ b/lambdacontext/logger_test.go @@ -0,0 +1,375 @@ +//go:build go1.21 +// +build go1.21 + +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +package lambdacontext + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttrs(t *testing.T) { + lc := &LambdaContext{ + AwsRequestID: "test-request-id", + } + attrs := lc.Attrs() + assert.Equal(t, []any{"requestId", "test-request-id"}, attrs) +} + +func TestReplaceAttr(t *testing.T) { + tests := []struct { + name string + groups []string + attr slog.Attr + expected slog.Attr + }{ + { + name: "time to timestamp", + groups: nil, + attr: slog.String(slog.TimeKey, "2025-01-09T12:00:00Z"), + expected: slog.String("timestamp", "2025-01-09T12:00:00Z"), + }, + { + name: "msg to message", + groups: nil, + attr: slog.String(slog.MessageKey, "test message"), + expected: slog.String("message", "test message"), + }, + { + name: "level unchanged", + groups: nil, + attr: slog.String(slog.LevelKey, "INFO"), + expected: slog.String(slog.LevelKey, "INFO"), + }, + { + name: "custom key unchanged", + groups: nil, + attr: slog.String("customKey", "value"), + expected: slog.String("customKey", "value"), + }, + { + name: "grouped attrs not replaced", + groups: []string{"group1"}, + attr: slog.String(slog.TimeKey, "2025-01-09T12:00:00Z"), + expected: slog.String(slog.TimeKey, "2025-01-09T12:00:00Z"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ReplaceAttr(tt.groups, tt.attr) + assert.Equal(t, tt.expected.Key, result.Key) + assert.Equal(t, tt.expected.Value.String(), result.Value.String()) + }) + } +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + name string + input string + expected slog.Level + }{ + {"DEBUG", "DEBUG", slog.LevelDebug}, + {"INFO", "INFO", slog.LevelInfo}, + {"WARN", "WARN", slog.LevelWarn}, + {"ERROR", "ERROR", slog.LevelError}, + {"empty", "", slog.LevelInfo}, + {"INVALID", "INVALID", slog.LevelInfo}, + {"lowercase debug", "debug", slog.LevelInfo}, // case sensitive + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + LogLevelName = tt.input + result := parseLogLevel() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHandler_JSONFormat(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{handler: baseHandler} + + lc := &LambdaContext{AwsRequestID: "test-request-123"} + ctx := NewContext(context.Background(), lc) + + logger := slog.New(handler) + logger.InfoContext(ctx, "test message", "key", "value") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "INFO", logOutput["level"]) + assert.Equal(t, "test message", logOutput["message"]) + assert.Equal(t, "test-request-123", logOutput["requestId"]) + assert.Equal(t, "value", logOutput["key"]) + assert.Contains(t, logOutput, "timestamp") + assert.NotContains(t, logOutput, "functionArn") + assert.NotContains(t, logOutput, "tenantId") +} + +func TestHandler_NoLambdaContext(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{handler: baseHandler} + + // Create context WITHOUT Lambda context + ctx := context.Background() + + logger := slog.New(handler) + logger.InfoContext(ctx, "no context message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "no context message", logOutput["message"]) + assert.NotContains(t, logOutput, "requestId") +} + +func TestHandler_ConcurrencySafe(t *testing.T) { + // This test verifies that different contexts get different requestIds + var buf1, buf2 bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + + // Create two handlers writing to different buffers + handler1 := &lambdaHandler{handler: slog.NewJSONHandler(&buf1, opts)} + handler2 := &lambdaHandler{handler: slog.NewJSONHandler(&buf2, opts)} + + // Create two contexts with different request IDs + lc1 := &LambdaContext{AwsRequestID: "request-aaa"} + lc2 := &LambdaContext{AwsRequestID: "request-bbb"} + + ctx1 := NewContext(context.Background(), lc1) + ctx2 := NewContext(context.Background(), lc2) + + // Log with both loggers + logger1 := slog.New(handler1) + logger2 := slog.New(handler2) + + logger1.InfoContext(ctx1, "message 1") + logger2.InfoContext(ctx2, "message 2") + + // Verify each has correct requestId + var output1, output2 map[string]interface{} + require.NoError(t, json.Unmarshal(buf1.Bytes(), &output1)) + require.NoError(t, json.Unmarshal(buf2.Bytes(), &output2)) + + assert.Equal(t, "request-aaa", output1["requestId"]) + assert.Equal(t, "request-bbb", output2["requestId"]) +} + +func TestHandler_SharedHandlerConcurrencySafe(t *testing.T) { + // This is the key test: a SINGLE shared handler should still produce + // correct requestIds for different contexts + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + + // Single shared handler + sharedHandler := &lambdaHandler{handler: slog.NewJSONHandler(&buf, opts)} + logger := slog.New(sharedHandler) + + // Create two contexts with different request IDs + lc1 := &LambdaContext{AwsRequestID: "request-aaa"} + lc2 := &LambdaContext{AwsRequestID: "request-bbb"} + + ctx1 := NewContext(context.Background(), lc1) + ctx2 := NewContext(context.Background(), lc2) + + // Log with the same logger but different contexts + logger.InfoContext(ctx1, "message 1") + logger.InfoContext(ctx2, "message 2") + + // Parse both lines + lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) + require.Len(t, lines, 2) + + var output1, output2 map[string]interface{} + require.NoError(t, json.Unmarshal(lines[0], &output1)) + require.NoError(t, json.Unmarshal(lines[1], &output2)) + + // Verify each log line has the correct requestId from its context + assert.Equal(t, "request-aaa", output1["requestId"]) + assert.Equal(t, "request-bbb", output2["requestId"]) +} + +func TestHandler_WithAttrs(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{handler: baseHandler} + + lc := &LambdaContext{AwsRequestID: "test-request"} + ctx := NewContext(context.Background(), lc) + + // Create logger with additional attrs + logger := slog.New(handler).With("service", "test-service") + logger.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "test-request", logOutput["requestId"]) + assert.Equal(t, "test-service", logOutput["service"]) +} + +func TestHandler_WithGroup(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{handler: baseHandler} + + lc := &LambdaContext{AwsRequestID: "test-request"} + ctx := NewContext(context.Background(), lc) + + // Create logger with a group + logger := slog.New(handler).WithGroup("app").With("version", "1.0") + logger.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + // When using WithGroup, the requestId from Handle() goes into the group + // because the underlying handler has been wrapped with WithGroup + app, ok := logOutput["app"].(map[string]interface{}) + require.True(t, ok, "expected 'app' group in output: %s", buf.String()) + assert.Equal(t, "1.0", app["version"]) + assert.Equal(t, "test-request", app["requestId"]) +} + +func TestHandler_WithOptionalFields(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{ + handler: baseHandler, + fields: []Field{FunctionArn, TenantId}, + } + + lc := &LambdaContext{ + AwsRequestID: "test-request-123", + InvokedFunctionArn: "arn:aws:lambda:us-east-1:123456789:function:test", + TenantID: "tenant-abc", + } + ctx := NewContext(context.Background(), lc) + + logger := slog.New(handler) + logger.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "test-request-123", logOutput["requestId"]) + assert.Equal(t, "arn:aws:lambda:us-east-1:123456789:function:test", logOutput["functionArn"]) + assert.Equal(t, "tenant-abc", logOutput["tenantId"]) +} + +func TestHandler_WithFunctionArnOnly(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{ + handler: baseHandler, + fields: []Field{FunctionArn}, + } + + lc := &LambdaContext{ + AwsRequestID: "test-request-123", + InvokedFunctionArn: "arn:aws:lambda:us-east-1:123456789:function:test", + TenantID: "tenant-abc", + } + ctx := NewContext(context.Background(), lc) + + logger := slog.New(handler) + logger.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "test-request-123", logOutput["requestId"]) + assert.Equal(t, "arn:aws:lambda:us-east-1:123456789:function:test", logOutput["functionArn"]) + assert.NotContains(t, logOutput, "tenantId") +} + +func TestHandler_OptionalFieldsEmpty(t *testing.T) { + var buf bytes.Buffer + + opts := &slog.HandlerOptions{ + Level: slog.LevelInfo, + ReplaceAttr: ReplaceAttr, + } + baseHandler := slog.NewJSONHandler(&buf, opts) + handler := &lambdaHandler{ + handler: baseHandler, + fields: []Field{FunctionArn, TenantId}, + } + + // Only requestId is set, optional fields are empty + lc := &LambdaContext{ + AwsRequestID: "test-request-123", + InvokedFunctionArn: "", + TenantID: "", + } + ctx := NewContext(context.Background(), lc) + + logger := slog.New(handler) + logger.InfoContext(ctx, "test message") + + var logOutput map[string]interface{} + err := json.Unmarshal(buf.Bytes(), &logOutput) + require.NoError(t, err) + + assert.Equal(t, "test-request-123", logOutput["requestId"]) + assert.NotContains(t, logOutput, "functionArn") + assert.NotContains(t, logOutput, "tenantId") +} From 31a7a20cfca30bcb4c55bd7ca6b2e4440ed8c154 Mon Sep 17 00:00:00 2001 From: anzheyazzz Date: Wed, 14 Jan 2026 13:50:53 +0000 Subject: [PATCH 2/4] Address PR review feedback - Rename Handler() to LogHandler() - Rename LogFormatName/LogLevelName to LogFormat/LogLevel - Use functional options pattern with WithFields() - Use Field functions (FieldFunctionARN, FieldTenantID) for immutability - Add example tests in separate file with go1.21 build tag --- lambdacontext/context.go | 12 ++--- lambdacontext/example_logger_test.go | 54 +++++++++++++++++++++ lambdacontext/logger.go | 67 ++++++++++++++++---------- lambdacontext/logger_test.go | 70 +++++++++++++++------------- 4 files changed, 141 insertions(+), 62 deletions(-) create mode 100644 lambdacontext/example_logger_test.go diff --git a/lambdacontext/context.go b/lambdacontext/context.go index 78091bae..9e2e0f62 100644 --- a/lambdacontext/context.go +++ b/lambdacontext/context.go @@ -15,11 +15,11 @@ import ( "strconv" ) -// LogFormatName is the name of the Log Format, either TEXT or JSON -var LogFormatName string +// LogFormat is the log format, either TEXT or JSON (from AWS_LAMBDA_LOG_FORMAT) +var LogFormat string -// LogLevelName is the name of the Log Levels for structured logging. Only available when LogFormatName is JSON -var LogLevelName string +// LogLevel is the log level for structured logging (from AWS_LAMBDA_LOG_LEVEL). Only available when LogFormat is JSON +var LogLevel string // LogGroupName is the name of the log group that contains the log streams of the current Lambda Function var LogGroupName string @@ -39,8 +39,8 @@ var FunctionVersion string var maxConcurrency int func init() { - LogFormatName = os.Getenv("AWS_LAMBDA_LOG_FORMAT") - LogLevelName = os.Getenv("AWS_LAMBDA_LOG_LEVEL") + LogFormat = os.Getenv("AWS_LAMBDA_LOG_FORMAT") + LogLevel = os.Getenv("AWS_LAMBDA_LOG_LEVEL") LogGroupName = os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME") LogStreamName = os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME") FunctionName = os.Getenv("AWS_LAMBDA_FUNCTION_NAME") diff --git a/lambdacontext/example_logger_test.go b/lambdacontext/example_logger_test.go new file mode 100644 index 00000000..b002980c --- /dev/null +++ b/lambdacontext/example_logger_test.go @@ -0,0 +1,54 @@ +//go:build go1.21 +// +build go1.21 + +package lambdacontext_test + +import ( + "context" + "log/slog" + + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-lambda-go/lambdacontext" +) + +// ExampleLogHandler demonstrates basic usage of LogHandler for structured logging. +// The handler automatically injects requestId from Lambda context into each log record. +func ExampleLogHandler() { + // Set up the Lambda-aware slog handler + slog.SetDefault(slog.New(lambdacontext.LogHandler())) + + lambda.Start(func(ctx context.Context) (string, error) { + // Use slog.InfoContext to include Lambda context in logs + slog.InfoContext(ctx, "processing request", "action", "example") + return "success", nil + }) +} + +// ExampleLogHandler_withFields demonstrates LogHandler with additional fields. +// Use WithFields with FieldFunctionARN() and FieldTenantID() to include extra context. +func ExampleLogHandler_withFields() { + // Set up handler with function ARN and tenant ID fields + slog.SetDefault(slog.New(lambdacontext.LogHandler( + lambdacontext.WithFields(lambdacontext.FieldFunctionARN(), lambdacontext.FieldTenantID()), + ))) + + lambda.Start(func(ctx context.Context) (string, error) { + slog.InfoContext(ctx, "multi-tenant request", "tenant", "acme-corp") + return "success", nil + }) +} + +// ExampleWithFields demonstrates using WithFields to include specific Lambda context fields. +func ExampleWithFields() { + // Include only function ARN + handler := lambdacontext.LogHandler( + lambdacontext.WithFields(lambdacontext.FieldFunctionARN()), + ) + slog.SetDefault(slog.New(handler)) + + lambda.Start(func(ctx context.Context) (string, error) { + // Log output will include "functionArn" field + slog.InfoContext(ctx, "function invoked") + return "success", nil + }) +} diff --git a/lambdacontext/logger.go b/lambdacontext/logger.go index 961bc465..2932fbdb 100644 --- a/lambdacontext/logger.go +++ b/lambdacontext/logger.go @@ -11,44 +11,63 @@ import ( "os" ) -// Field represents an optional field to include in log records. +// Field represents a Lambda context field to include in log records. type Field struct { key string value func(*LambdaContext) string } -// FunctionArn includes the invoked function ARN in log records. -var FunctionArn = Field{"functionArn", func(lc *LambdaContext) string { return lc.InvokedFunctionArn }} //nolint: staticcheck +// FieldFunctionARN returns a Field that includes the invoked function ARN in log records. +func FieldFunctionARN() Field { + return Field{"functionArn", func(lc *LambdaContext) string { return lc.InvokedFunctionArn }} +} + +// FieldTenantID returns a Field that includes the tenant ID in log records (for multi-tenant functions). +func FieldTenantID() Field { + return Field{"tenantId", func(lc *LambdaContext) string { return lc.TenantID }} +} + +// logOptions holds configuration for the Lambda log handler. +type logOptions struct { + fields []Field +} + +// LogOption is a functional option for configuring the Lambda log handler. +type LogOption func(*logOptions) -// TenantId includes the tenant ID in log records (for multi-tenant functions). -var TenantId = Field{"tenantId", func(lc *LambdaContext) string { return lc.TenantID }} //nolint: staticcheck +// WithFields includes the specified fields in log records. +func WithFields(fields ...Field) LogOption { + return func(o *logOptions) { + o.fields = append(o.fields, fields...) + } +} -// Handler returns a [slog.Handler] for AWS Lambda structured logging. +// LogHandler returns a [slog.Handler] for AWS Lambda structured logging. // It reads AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL from environment, // and injects requestId from Lambda context into each log record. // -// By default, only requestId is injected. Pass optional fields to include more: -// -// // Default: only requestId -// slog.SetDefault(slog.New(lambdacontext.Handler())) -// -// // With functionArn and tenantId -// slog.SetDefault(slog.New(lambdacontext.Handler(lambdacontext.FunctionArn, lambdacontext.TenantId))) -func Handler(fields ...Field) slog.Handler { +// By default, only requestId is injected. Use WithFields to include more. +// See the package examples for usage. +func LogHandler(opts ...LogOption) slog.Handler { + options := &logOptions{} + for _, opt := range opts { + opt(options) + } + level := parseLogLevel() - opts := &slog.HandlerOptions{ + handlerOpts := &slog.HandlerOptions{ Level: level, ReplaceAttr: ReplaceAttr, } var h slog.Handler - if LogFormatName == "JSON" { - h = slog.NewJSONHandler(os.Stdout, opts) + if LogFormat == "JSON" { + h = slog.NewJSONHandler(os.Stdout, handlerOpts) } else { - h = slog.NewTextHandler(os.Stdout, opts) + h = slog.NewTextHandler(os.Stdout, handlerOpts) } - return &lambdaHandler{handler: h, fields: fields} + return &lambdaHandler{handler: h, fields: options.fields} } // ReplaceAttr maps slog's default keys to AWS Lambda's log format (time->timestamp, msg->message). @@ -67,7 +86,7 @@ func ReplaceAttr(groups []string, attr slog.Attr) slog.Attr { } // Attrs returns Lambda context fields as slog-compatible key-value pairs. -// For most use cases, using [Handler] with slog.InfoContext is preferred. +// For most use cases, using [LogHandler] with slog.InfoContext is preferred. func (lc *LambdaContext) Attrs() []any { return []any{"requestId", lc.AwsRequestID} } @@ -88,9 +107,9 @@ func (h *lambdaHandler) Handle(ctx context.Context, r slog.Record) error { if lc, ok := FromContext(ctx); ok { r.AddAttrs(slog.String("requestId", lc.AwsRequestID)) - for _, f := range h.fields { - if v := f.value(lc); v != "" { - r.AddAttrs(slog.String(f.key, v)) + for _, field := range h.fields { + if v := field.value(lc); v != "" { + r.AddAttrs(slog.String(field.key, v)) } } } @@ -114,7 +133,7 @@ func (h *lambdaHandler) WithGroup(name string) slog.Handler { } func parseLogLevel() slog.Level { - switch LogLevelName { + switch LogLevel { case "DEBUG": return slog.LevelDebug case "INFO": diff --git a/lambdacontext/logger_test.go b/lambdacontext/logger_test.go index a910ab10..b45826bd 100644 --- a/lambdacontext/logger_test.go +++ b/lambdacontext/logger_test.go @@ -84,19 +84,19 @@ func TestParseLogLevel(t *testing.T) { {"ERROR", "ERROR", slog.LevelError}, {"empty", "", slog.LevelInfo}, {"INVALID", "INVALID", slog.LevelInfo}, - {"lowercase debug", "debug", slog.LevelInfo}, // case sensitive + {"lowercase debug", "debug", slog.LevelInfo}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - LogLevelName = tt.input + LogLevel = tt.input result := parseLogLevel() assert.Equal(t, tt.expected, result) }) } } -func TestHandler_JSONFormat(t *testing.T) { +func TestLogHandler_JSONFormat(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -125,7 +125,7 @@ func TestHandler_JSONFormat(t *testing.T) { assert.NotContains(t, logOutput, "tenantId") } -func TestHandler_NoLambdaContext(t *testing.T) { +func TestLogHandler_NoLambdaContext(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -135,7 +135,6 @@ func TestHandler_NoLambdaContext(t *testing.T) { baseHandler := slog.NewJSONHandler(&buf, opts) handler := &lambdaHandler{handler: baseHandler} - // Create context WITHOUT Lambda context ctx := context.Background() logger := slog.New(handler) @@ -149,8 +148,7 @@ func TestHandler_NoLambdaContext(t *testing.T) { assert.NotContains(t, logOutput, "requestId") } -func TestHandler_ConcurrencySafe(t *testing.T) { - // This test verifies that different contexts get different requestIds +func TestLogHandler_ConcurrencySafe(t *testing.T) { var buf1, buf2 bytes.Buffer opts := &slog.HandlerOptions{ @@ -158,25 +156,21 @@ func TestHandler_ConcurrencySafe(t *testing.T) { ReplaceAttr: ReplaceAttr, } - // Create two handlers writing to different buffers handler1 := &lambdaHandler{handler: slog.NewJSONHandler(&buf1, opts)} handler2 := &lambdaHandler{handler: slog.NewJSONHandler(&buf2, opts)} - // Create two contexts with different request IDs lc1 := &LambdaContext{AwsRequestID: "request-aaa"} lc2 := &LambdaContext{AwsRequestID: "request-bbb"} ctx1 := NewContext(context.Background(), lc1) ctx2 := NewContext(context.Background(), lc2) - // Log with both loggers logger1 := slog.New(handler1) logger2 := slog.New(handler2) logger1.InfoContext(ctx1, "message 1") logger2.InfoContext(ctx2, "message 2") - // Verify each has correct requestId var output1, output2 map[string]interface{} require.NoError(t, json.Unmarshal(buf1.Bytes(), &output1)) require.NoError(t, json.Unmarshal(buf2.Bytes(), &output2)) @@ -185,9 +179,7 @@ func TestHandler_ConcurrencySafe(t *testing.T) { assert.Equal(t, "request-bbb", output2["requestId"]) } -func TestHandler_SharedHandlerConcurrencySafe(t *testing.T) { - // This is the key test: a SINGLE shared handler should still produce - // correct requestIds for different contexts +func TestLogHandler_SharedHandlerConcurrencySafe(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -195,22 +187,18 @@ func TestHandler_SharedHandlerConcurrencySafe(t *testing.T) { ReplaceAttr: ReplaceAttr, } - // Single shared handler sharedHandler := &lambdaHandler{handler: slog.NewJSONHandler(&buf, opts)} logger := slog.New(sharedHandler) - // Create two contexts with different request IDs lc1 := &LambdaContext{AwsRequestID: "request-aaa"} lc2 := &LambdaContext{AwsRequestID: "request-bbb"} ctx1 := NewContext(context.Background(), lc1) ctx2 := NewContext(context.Background(), lc2) - // Log with the same logger but different contexts logger.InfoContext(ctx1, "message 1") logger.InfoContext(ctx2, "message 2") - // Parse both lines lines := bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) require.Len(t, lines, 2) @@ -218,12 +206,11 @@ func TestHandler_SharedHandlerConcurrencySafe(t *testing.T) { require.NoError(t, json.Unmarshal(lines[0], &output1)) require.NoError(t, json.Unmarshal(lines[1], &output2)) - // Verify each log line has the correct requestId from its context assert.Equal(t, "request-aaa", output1["requestId"]) assert.Equal(t, "request-bbb", output2["requestId"]) } -func TestHandler_WithAttrs(t *testing.T) { +func TestLogHandler_WithAttrs(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -236,7 +223,6 @@ func TestHandler_WithAttrs(t *testing.T) { lc := &LambdaContext{AwsRequestID: "test-request"} ctx := NewContext(context.Background(), lc) - // Create logger with additional attrs logger := slog.New(handler).With("service", "test-service") logger.InfoContext(ctx, "test message") @@ -248,7 +234,7 @@ func TestHandler_WithAttrs(t *testing.T) { assert.Equal(t, "test-service", logOutput["service"]) } -func TestHandler_WithGroup(t *testing.T) { +func TestLogHandler_WithGroup(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -261,7 +247,6 @@ func TestHandler_WithGroup(t *testing.T) { lc := &LambdaContext{AwsRequestID: "test-request"} ctx := NewContext(context.Background(), lc) - // Create logger with a group logger := slog.New(handler).WithGroup("app").With("version", "1.0") logger.InfoContext(ctx, "test message") @@ -269,15 +254,13 @@ func TestHandler_WithGroup(t *testing.T) { err := json.Unmarshal(buf.Bytes(), &logOutput) require.NoError(t, err) - // When using WithGroup, the requestId from Handle() goes into the group - // because the underlying handler has been wrapped with WithGroup app, ok := logOutput["app"].(map[string]interface{}) require.True(t, ok, "expected 'app' group in output: %s", buf.String()) assert.Equal(t, "1.0", app["version"]) assert.Equal(t, "test-request", app["requestId"]) } -func TestHandler_WithOptionalFields(t *testing.T) { +func TestLogHandler_WithFields(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -287,7 +270,7 @@ func TestHandler_WithOptionalFields(t *testing.T) { baseHandler := slog.NewJSONHandler(&buf, opts) handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FunctionArn, TenantId}, + fields: []Field{FieldFunctionARN(), FieldTenantID()}, } lc := &LambdaContext{ @@ -309,7 +292,7 @@ func TestHandler_WithOptionalFields(t *testing.T) { assert.Equal(t, "tenant-abc", logOutput["tenantId"]) } -func TestHandler_WithFunctionArnOnly(t *testing.T) { +func TestLogHandler_WithFieldFunctionARNOnly(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -319,7 +302,7 @@ func TestHandler_WithFunctionArnOnly(t *testing.T) { baseHandler := slog.NewJSONHandler(&buf, opts) handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FunctionArn}, + fields: []Field{FieldFunctionARN()}, } lc := &LambdaContext{ @@ -341,7 +324,7 @@ func TestHandler_WithFunctionArnOnly(t *testing.T) { assert.NotContains(t, logOutput, "tenantId") } -func TestHandler_OptionalFieldsEmpty(t *testing.T) { +func TestLogHandler_FieldsEmpty(t *testing.T) { var buf bytes.Buffer opts := &slog.HandlerOptions{ @@ -351,10 +334,9 @@ func TestHandler_OptionalFieldsEmpty(t *testing.T) { baseHandler := slog.NewJSONHandler(&buf, opts) handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FunctionArn, TenantId}, + fields: []Field{FieldFunctionARN(), FieldTenantID()}, } - // Only requestId is set, optional fields are empty lc := &LambdaContext{ AwsRequestID: "test-request-123", InvokedFunctionArn: "", @@ -373,3 +355,27 @@ func TestHandler_OptionalFieldsEmpty(t *testing.T) { assert.NotContains(t, logOutput, "functionArn") assert.NotContains(t, logOutput, "tenantId") } + +func TestWithFields(t *testing.T) { + options := &logOptions{} + WithFields(FieldFunctionARN(), FieldTenantID())(options) + + assert.Len(t, options.fields, 2) + assert.Equal(t, "functionArn", options.fields[0].key) + assert.Equal(t, "tenantId", options.fields[1].key) +} + +func TestFieldFunctions(t *testing.T) { + lc := &LambdaContext{ + InvokedFunctionArn: "arn:aws:lambda:us-east-1:123456789:function:test", + TenantID: "tenant-abc", + } + + arnField := FieldFunctionARN() + assert.Equal(t, "functionArn", arnField.key) + assert.Equal(t, "arn:aws:lambda:us-east-1:123456789:function:test", arnField.value(lc)) + + tenantField := FieldTenantID() + assert.Equal(t, "tenantId", tenantField.key) + assert.Equal(t, "tenant-abc", tenantField.value(lc)) +} From a4e53b235727d6b6a83af98894ff30d67384132e Mon Sep 17 00:00:00 2001 From: anzheyazzz Date: Wed, 14 Jan 2026 23:19:20 +0000 Subject: [PATCH 3/4] Address PR review feedback --- lambdacontext/example_logger_test.go | 43 ++++++++++------ lambdacontext/logger.go | 49 +++++++++--------- lambdacontext/logger_test.go | 76 ++++++++++++++++++---------- 3 files changed, 100 insertions(+), 68 deletions(-) diff --git a/lambdacontext/example_logger_test.go b/lambdacontext/example_logger_test.go index b002980c..e20f422e 100644 --- a/lambdacontext/example_logger_test.go +++ b/lambdacontext/example_logger_test.go @@ -11,11 +11,11 @@ import ( "github.com/aws/aws-lambda-go/lambdacontext" ) -// ExampleLogHandler demonstrates basic usage of LogHandler for structured logging. -// The handler automatically injects requestId from Lambda context into each log record. -func ExampleLogHandler() { - // Set up the Lambda-aware slog handler - slog.SetDefault(slog.New(lambdacontext.LogHandler())) +// ExampleNewLogger demonstrates the simplest usage of NewLogger for structured logging. +// The logger automatically injects requestId from Lambda context into each log record. +func ExampleNewLogger() { + // Set up the Lambda-aware slog logger + slog.SetDefault(lambdacontext.NewLogger()) lambda.Start(func(ctx context.Context) (string, error) { // Use slog.InfoContext to include Lambda context in logs @@ -24,12 +24,24 @@ func ExampleLogHandler() { }) } -// ExampleLogHandler_withFields demonstrates LogHandler with additional fields. -// Use WithFields with FieldFunctionARN() and FieldTenantID() to include extra context. -func ExampleLogHandler_withFields() { +// ExampleNewLogHandler demonstrates using NewLogHandler for more control. +func ExampleNewLogHandler() { + // Set up the Lambda-aware slog handler + slog.SetDefault(slog.New(lambdacontext.NewLogHandler())) + + lambda.Start(func(ctx context.Context) (string, error) { + slog.InfoContext(ctx, "processing request", "action", "example") + return "success", nil + }) +} + +// ExampleNewLogHandler_withOptions demonstrates NewLogHandler with additional fields. +// Use WithFunctionARN() and WithTenantID() to include extra context. +func ExampleNewLogHandler_withOptions() { // Set up handler with function ARN and tenant ID fields - slog.SetDefault(slog.New(lambdacontext.LogHandler( - lambdacontext.WithFields(lambdacontext.FieldFunctionARN(), lambdacontext.FieldTenantID()), + slog.SetDefault(slog.New(lambdacontext.NewLogHandler( + lambdacontext.WithFunctionARN(), + lambdacontext.WithTenantID(), ))) lambda.Start(func(ctx context.Context) (string, error) { @@ -38,13 +50,12 @@ func ExampleLogHandler_withFields() { }) } -// ExampleWithFields demonstrates using WithFields to include specific Lambda context fields. -func ExampleWithFields() { +// ExampleWithFunctionARN demonstrates using WithFunctionARN to include the function ARN. +func ExampleWithFunctionARN() { // Include only function ARN - handler := lambdacontext.LogHandler( - lambdacontext.WithFields(lambdacontext.FieldFunctionARN()), - ) - slog.SetDefault(slog.New(handler)) + slog.SetDefault(lambdacontext.NewLogger( + lambdacontext.WithFunctionARN(), + )) lambda.Start(func(ctx context.Context) (string, error) { // Log output will include "functionArn" field diff --git a/lambdacontext/logger.go b/lambdacontext/logger.go index 2932fbdb..835c4c93 100644 --- a/lambdacontext/logger.go +++ b/lambdacontext/logger.go @@ -11,44 +11,41 @@ import ( "os" ) -// Field represents a Lambda context field to include in log records. -type Field struct { +// field represents a Lambda context field to include in log records. +type field struct { key string value func(*LambdaContext) string } -// FieldFunctionARN returns a Field that includes the invoked function ARN in log records. -func FieldFunctionARN() Field { - return Field{"functionArn", func(lc *LambdaContext) string { return lc.InvokedFunctionArn }} -} - -// FieldTenantID returns a Field that includes the tenant ID in log records (for multi-tenant functions). -func FieldTenantID() Field { - return Field{"tenantId", func(lc *LambdaContext) string { return lc.TenantID }} -} - // logOptions holds configuration for the Lambda log handler. type logOptions struct { - fields []Field + fields []field } // LogOption is a functional option for configuring the Lambda log handler. type LogOption func(*logOptions) -// WithFields includes the specified fields in log records. -func WithFields(fields ...Field) LogOption { +// WithFunctionARN includes the invoked function ARN in log records. +func WithFunctionARN() LogOption { + return func(o *logOptions) { + o.fields = append(o.fields, field{"functionArn", func(lc *LambdaContext) string { return lc.InvokedFunctionArn }}) + } +} + +// WithTenantID includes the tenant ID in log records (for multi-tenant functions). +func WithTenantID() LogOption { return func(o *logOptions) { - o.fields = append(o.fields, fields...) + o.fields = append(o.fields, field{"tenantId", func(lc *LambdaContext) string { return lc.TenantID }}) } } -// LogHandler returns a [slog.Handler] for AWS Lambda structured logging. +// NewLogHandler returns a [slog.Handler] for AWS Lambda structured logging. // It reads AWS_LAMBDA_LOG_FORMAT and AWS_LAMBDA_LOG_LEVEL from environment, // and injects requestId from Lambda context into each log record. // -// By default, only requestId is injected. Use WithFields to include more. +// By default, only requestId is injected. Use WithFunctionARN or WithTenantID to include more. // See the package examples for usage. -func LogHandler(opts ...LogOption) slog.Handler { +func NewLogHandler(opts ...LogOption) slog.Handler { options := &logOptions{} for _, opt := range opts { opt(options) @@ -70,6 +67,12 @@ func LogHandler(opts ...LogOption) slog.Handler { return &lambdaHandler{handler: h, fields: options.fields} } +// NewLogger returns a [*slog.Logger] configured for AWS Lambda structured logging. +// This is a convenience function equivalent to slog.New(NewLogHandler(opts...)). +func NewLogger(opts ...LogOption) *slog.Logger { + return slog.New(NewLogHandler(opts...)) +} + // ReplaceAttr maps slog's default keys to AWS Lambda's log format (time->timestamp, msg->message). func ReplaceAttr(groups []string, attr slog.Attr) slog.Attr { if len(groups) > 0 { @@ -85,16 +88,10 @@ func ReplaceAttr(groups []string, attr slog.Attr) slog.Attr { return attr } -// Attrs returns Lambda context fields as slog-compatible key-value pairs. -// For most use cases, using [LogHandler] with slog.InfoContext is preferred. -func (lc *LambdaContext) Attrs() []any { - return []any{"requestId", lc.AwsRequestID} -} - // lambdaHandler wraps a slog.Handler to inject Lambda context fields. type lambdaHandler struct { handler slog.Handler - fields []Field + fields []field } // Enabled implements slog.Handler. diff --git a/lambdacontext/logger_test.go b/lambdacontext/logger_test.go index b45826bd..80b123d3 100644 --- a/lambdacontext/logger_test.go +++ b/lambdacontext/logger_test.go @@ -16,14 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestAttrs(t *testing.T) { - lc := &LambdaContext{ - AwsRequestID: "test-request-id", - } - attrs := lc.Attrs() - assert.Equal(t, []any{"requestId", "test-request-id"}, attrs) -} - func TestReplaceAttr(t *testing.T) { tests := []struct { name string @@ -268,9 +260,15 @@ func TestLogHandler_WithFields(t *testing.T) { ReplaceAttr: ReplaceAttr, } baseHandler := slog.NewJSONHandler(&buf, opts) + + // Create options with fields + options := &logOptions{} + WithFunctionARN()(options) + WithTenantID()(options) + handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FieldFunctionARN(), FieldTenantID()}, + fields: options.fields, } lc := &LambdaContext{ @@ -300,9 +298,13 @@ func TestLogHandler_WithFieldFunctionARNOnly(t *testing.T) { ReplaceAttr: ReplaceAttr, } baseHandler := slog.NewJSONHandler(&buf, opts) + + options := &logOptions{} + WithFunctionARN()(options) + handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FieldFunctionARN()}, + fields: options.fields, } lc := &LambdaContext{ @@ -332,9 +334,14 @@ func TestLogHandler_FieldsEmpty(t *testing.T) { ReplaceAttr: ReplaceAttr, } baseHandler := slog.NewJSONHandler(&buf, opts) + + options := &logOptions{} + WithFunctionARN()(options) + WithTenantID()(options) + handler := &lambdaHandler{ handler: baseHandler, - fields: []Field{FieldFunctionARN(), FieldTenantID()}, + fields: options.fields, } lc := &LambdaContext{ @@ -356,26 +363,43 @@ func TestLogHandler_FieldsEmpty(t *testing.T) { assert.NotContains(t, logOutput, "tenantId") } -func TestWithFields(t *testing.T) { +func TestWithFunctionARN(t *testing.T) { options := &logOptions{} - WithFields(FieldFunctionARN(), FieldTenantID())(options) + WithFunctionARN()(options) - assert.Len(t, options.fields, 2) + assert.Len(t, options.fields, 1) assert.Equal(t, "functionArn", options.fields[0].key) - assert.Equal(t, "tenantId", options.fields[1].key) + + lc := &LambdaContext{InvokedFunctionArn: "arn:aws:lambda:us-east-1:123456789:function:test"} + assert.Equal(t, "arn:aws:lambda:us-east-1:123456789:function:test", options.fields[0].value(lc)) } -func TestFieldFunctions(t *testing.T) { - lc := &LambdaContext{ - InvokedFunctionArn: "arn:aws:lambda:us-east-1:123456789:function:test", - TenantID: "tenant-abc", - } +func TestWithTenantID(t *testing.T) { + options := &logOptions{} + WithTenantID()(options) + + assert.Len(t, options.fields, 1) + assert.Equal(t, "tenantId", options.fields[0].key) + + lc := &LambdaContext{TenantID: "tenant-abc"} + assert.Equal(t, "tenant-abc", options.fields[0].value(lc)) +} + +func TestNewLogger(t *testing.T) { + LogFormat = "JSON" + LogLevel = "INFO" + + logger := NewLogger() + assert.NotNil(t, logger) +} + +func TestNewLogHandler(t *testing.T) { + LogFormat = "JSON" + LogLevel = "INFO" - arnField := FieldFunctionARN() - assert.Equal(t, "functionArn", arnField.key) - assert.Equal(t, "arn:aws:lambda:us-east-1:123456789:function:test", arnField.value(lc)) + handler := NewLogHandler() + assert.NotNil(t, handler) - tenantField := FieldTenantID() - assert.Equal(t, "tenantId", tenantField.key) - assert.Equal(t, "tenant-abc", tenantField.value(lc)) + handlerWithOpts := NewLogHandler(WithFunctionARN(), WithTenantID()) + assert.NotNil(t, handlerWithOpts) } From 32cebc38251f73a7ae771967abbb95849e71977e Mon Sep 17 00:00:00 2001 From: anzheyazzz Date: Thu, 15 Jan 2026 17:37:39 +0000 Subject: [PATCH 4/4] stop exposing LogFormat and LogLevel from lambdacontext --- lambdacontext/context.go | 8 -------- lambdacontext/logger.go | 10 ++++++++-- lambdacontext/logger_test.go | 8 +------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/lambdacontext/context.go b/lambdacontext/context.go index 9e2e0f62..d75d8282 100644 --- a/lambdacontext/context.go +++ b/lambdacontext/context.go @@ -15,12 +15,6 @@ import ( "strconv" ) -// LogFormat is the log format, either TEXT or JSON (from AWS_LAMBDA_LOG_FORMAT) -var LogFormat string - -// LogLevel is the log level for structured logging (from AWS_LAMBDA_LOG_LEVEL). Only available when LogFormat is JSON -var LogLevel string - // LogGroupName is the name of the log group that contains the log streams of the current Lambda Function var LogGroupName string @@ -39,8 +33,6 @@ var FunctionVersion string var maxConcurrency int func init() { - LogFormat = os.Getenv("AWS_LAMBDA_LOG_FORMAT") - LogLevel = os.Getenv("AWS_LAMBDA_LOG_LEVEL") LogGroupName = os.Getenv("AWS_LAMBDA_LOG_GROUP_NAME") LogStreamName = os.Getenv("AWS_LAMBDA_LOG_STREAM_NAME") FunctionName = os.Getenv("AWS_LAMBDA_FUNCTION_NAME") diff --git a/lambdacontext/logger.go b/lambdacontext/logger.go index 835c4c93..4e0bd6de 100644 --- a/lambdacontext/logger.go +++ b/lambdacontext/logger.go @@ -11,6 +11,12 @@ import ( "os" ) +// logFormat is the log format from AWS_LAMBDA_LOG_FORMAT (TEXT or JSON) +var logFormat = os.Getenv("AWS_LAMBDA_LOG_FORMAT") + +// logLevel is the log level from AWS_LAMBDA_LOG_LEVEL +var logLevel = os.Getenv("AWS_LAMBDA_LOG_LEVEL") + // field represents a Lambda context field to include in log records. type field struct { key string @@ -58,7 +64,7 @@ func NewLogHandler(opts ...LogOption) slog.Handler { } var h slog.Handler - if LogFormat == "JSON" { + if logFormat == "JSON" { h = slog.NewJSONHandler(os.Stdout, handlerOpts) } else { h = slog.NewTextHandler(os.Stdout, handlerOpts) @@ -130,7 +136,7 @@ func (h *lambdaHandler) WithGroup(name string) slog.Handler { } func parseLogLevel() slog.Level { - switch LogLevel { + switch logLevel { case "DEBUG": return slog.LevelDebug case "INFO": diff --git a/lambdacontext/logger_test.go b/lambdacontext/logger_test.go index 80b123d3..a09e49c3 100644 --- a/lambdacontext/logger_test.go +++ b/lambdacontext/logger_test.go @@ -81,7 +81,7 @@ func TestParseLogLevel(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - LogLevel = tt.input + logLevel = tt.input result := parseLogLevel() assert.Equal(t, tt.expected, result) }) @@ -386,17 +386,11 @@ func TestWithTenantID(t *testing.T) { } func TestNewLogger(t *testing.T) { - LogFormat = "JSON" - LogLevel = "INFO" - logger := NewLogger() assert.NotNil(t, logger) } func TestNewLogHandler(t *testing.T) { - LogFormat = "JSON" - LogLevel = "INFO" - handler := NewLogHandler() assert.NotNil(t, handler)