diff --git a/.craft.yml b/.craft.yml index 238288e45..7b1850c0b 100644 --- a/.craft.yml +++ b/.craft.yml @@ -8,6 +8,9 @@ targets: - name: github tagPrefix: otel/v tagOnly: true + - name: github + tagPrefix: slog/v + tagOnly: true - name: registry sdks: github:getsentry/sentry-go: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e9e581af..e1917a070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Features + +- Add `sentryslog` integration ([#865](https://github.com/getsentry/sentry-go/pull/865)) + - Always set Mechanism Type to generic ([#896](https://github.com/getsentry/sentry-go/pull/897)) ### Misc diff --git a/Makefile b/Makefile index 876b6597a..9cc0959fd 100644 --- a/Makefile +++ b/Makefile @@ -58,9 +58,8 @@ test-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir ## Test with coverage en mod-tidy: ## Check go.mod tidiness set -e ; \ for dir in $(ALL_GO_MOD_DIRS); do \ - cd "$${dir}"; \ echo ">>> Running 'go mod tidy' for module: $${dir}"; \ - go mod tidy -go=1.21 -compat=1.21; \ + (cd "$${dir}" && go mod tidy -go=1.21 -compat=1.21); \ done; \ git diff --exit-code; .PHONY: mod-tidy @@ -68,12 +67,12 @@ mod-tidy: ## Check go.mod tidiness vet: ## Run "go vet" set -e ; \ for dir in $(ALL_GO_MOD_DIRS); do \ - cd "$${dir}"; \ echo ">>> Running 'go vet' for module: $${dir}"; \ - go vet ./...; \ + (cd "$${dir}" && go vet ./...); \ done; .PHONY: vet + lint: ## Lint (using "golangci-lint") golangci-lint run .PHONY: lint diff --git a/_examples/slog/main.go b/_examples/slog/main.go new file mode 100644 index 000000000..c886b2c31 --- /dev/null +++ b/_examples/slog/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "log" + "time" + + "log/slog" + + "github.com/getsentry/sentry-go" + sentryslog "github.com/getsentry/sentry-go/slog" +) + +func main() { + err := sentry.Init(sentry.ClientOptions{ + Dsn: "https://4d0ead95fb645aaab4fc95d21aaa6de1@o447951.ingest.us.sentry.io/4506954545758208", + EnableTracing: false, + }) + if err != nil { + log.Fatal(err) + } + + defer sentry.Flush(2 * time.Second) + + logger := slog.New(sentryslog.Option{Level: slog.LevelDebug}.NewSentryHandler()) + logger = logger.With("release", "v1.0.0") + + logger. + With( + slog.Group("user", + slog.String("id", "user-123"), + slog.Time("created_at", time.Now()), + ), + ). + With("environment", "dev"). + With("error", fmt.Errorf("an error")). + Error("a message") +} diff --git a/slog/common.go b/slog/common.go new file mode 100644 index 000000000..afa74b7fa --- /dev/null +++ b/slog/common.go @@ -0,0 +1,260 @@ +package sentryslog + +import ( + "context" + "encoding" + "fmt" + "log/slog" + "runtime" + "strconv" +) + +func source(sourceKey string, r *slog.Record) slog.Attr { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + var args []any + if f.Function != "" { + args = append(args, slog.String("function", f.Function)) + } + if f.File != "" { + args = append(args, slog.String("file", f.File)) + } + if f.Line != 0 { + args = append(args, slog.Int("line", f.Line)) + } + + return slog.Group(sourceKey, args...) +} + +type replaceAttrFn = func(groups []string, a slog.Attr) slog.Attr + +func replaceAttrs(fn replaceAttrFn, groups []string, attrs ...slog.Attr) []slog.Attr { + for i := range attrs { + attr := attrs[i] + value := attr.Value.Resolve() + if value.Kind() == slog.KindGroup { + attrs[i].Value = slog.GroupValue(replaceAttrs(fn, append(groups, attr.Key), value.Group()...)...) + } else if fn != nil { + attrs[i] = fn(groups, attr) + } + } + + return attrs +} + +func attrsToMap(attrs ...slog.Attr) map[string]any { + output := make(map[string]any, len(attrs)) + + attrsByKey := groupValuesByKey(attrs) + for k, values := range attrsByKey { + v := mergeAttrValues(values...) + if v.Kind() == slog.KindGroup { + output[k] = attrsToMap(v.Group()...) + } else { + output[k] = v.Any() + } + } + + return output +} + +func extractError(attrs []slog.Attr) ([]slog.Attr, error) { + for i := range attrs { + attr := attrs[i] + + if _, ok := errorKeys[attr.Key]; !ok { + continue + } + + if err, ok := attr.Value.Resolve().Any().(error); ok { + return append(attrs[:i], attrs[i+1:]...), err + } + } + + return attrs, nil +} + +func mergeAttrValues(values ...slog.Value) slog.Value { + v := values[0] + + for i := 1; i < len(values); i++ { + if v.Kind() != slog.KindGroup || values[i].Kind() != slog.KindGroup { + v = values[i] + continue + } + + v = slog.GroupValue(append(v.Group(), values[i].Group()...)...) + } + + return v +} + +func groupValuesByKey(attrs []slog.Attr) map[string][]slog.Value { + result := map[string][]slog.Value{} + + for _, item := range attrs { + key := item.Key + result[key] = append(result[key], item.Value) + } + + return result +} + +func attrsToString(attrs ...slog.Attr) map[string]string { + output := make(map[string]string, len(attrs)) + + for _, attr := range attrs { + k, v := attr.Key, attr.Value + output[k] = valueToString(v) + } + + return output +} + +func valueToString(v slog.Value) string { + switch v.Kind() { + case slog.KindAny, slog.KindLogValuer, slog.KindGroup: + return anyValueToString(v) + case slog.KindInt64: + return fmt.Sprintf("%d", v.Int64()) + case slog.KindUint64: + return fmt.Sprintf("%d", v.Uint64()) + case slog.KindFloat64: + return fmt.Sprintf("%f", v.Float64()) + case slog.KindString: + return v.String() + case slog.KindBool: + return strconv.FormatBool(v.Bool()) + case slog.KindDuration: + return v.Duration().String() + case slog.KindTime: + return v.Time().UTC().String() + } + return anyValueToString(v) +} + +func anyValueToString(v slog.Value) string { + tm, ok := v.Any().(encoding.TextMarshaler) + if !ok { + return fmt.Sprintf("%+v", v.Any()) + } + + data, err := tm.MarshalText() + if err != nil { + return fmt.Sprintf("%+v", v.Any()) + } + + return string(data) +} + +func appendRecordAttrsToAttrs(attrs []slog.Attr, groups []string, record *slog.Record) []slog.Attr { + output := make([]slog.Attr, len(attrs)) + copy(output, attrs) + + for i, j := 0, len(groups)-1; i < j; i, j = i+1, j-1 { + groups[i], groups[j] = groups[j], groups[i] + } + record.Attrs(func(attr slog.Attr) bool { + for i := range groups { + attr = slog.Group(groups[i], attr) + } + output = append(output, attr) + return true + }) + + return output +} + +func removeEmptyAttrs(attrs []slog.Attr) []slog.Attr { + result := []slog.Attr{} + + for _, attr := range attrs { + if attr.Key == "" { + continue + } + + if attr.Value.Kind() == slog.KindGroup { + values := removeEmptyAttrs(attr.Value.Group()) + if len(values) == 0 { + continue + } + attr.Value = slog.GroupValue(values...) + result = append(result, attr) + } else if !attr.Value.Equal(slog.Value{}) { + result = append(result, attr) + } + } + + return result +} + +func contextExtractor(ctx context.Context, fns []func(ctx context.Context) []slog.Attr) []slog.Attr { + attrs := []slog.Attr{} + for _, fn := range fns { + attrs = append(attrs, fn(ctx)...) + } + return attrs +} + +func appendAttrsToGroup(groups []string, actualAttrs []slog.Attr, newAttrs ...slog.Attr) []slog.Attr { + actualAttrsCopy := make([]slog.Attr, len(actualAttrs)) + copy(actualAttrsCopy, actualAttrs) + + if len(groups) == 0 { + return uniqAttrs(append(actualAttrsCopy, newAttrs...)) + } + + groupKey := groups[0] + for i := range actualAttrsCopy { + attr := actualAttrsCopy[i] + if attr.Key == groupKey && attr.Value.Kind() == slog.KindGroup { + actualAttrsCopy[i] = slog.Group(groupKey, toAnySlice(appendAttrsToGroup(groups[1:], attr.Value.Group(), newAttrs...))...) + return actualAttrsCopy + } + } + + return uniqAttrs( + append( + actualAttrsCopy, + slog.Group( + groupKey, + toAnySlice(appendAttrsToGroup(groups[1:], []slog.Attr{}, newAttrs...))..., + ), + ), + ) +} + +func toAnySlice(collection []slog.Attr) []any { + result := make([]any, len(collection)) + for i := range collection { + result[i] = collection[i] + } + return result +} + +func uniqAttrs(attrs []slog.Attr) []slog.Attr { + return uniqByLast(attrs, func(item slog.Attr) string { + return item.Key + }) +} + +func uniqByLast[T any, U comparable](collection []T, iteratee func(item T) U) []T { + result := make([]T, 0, len(collection)) + seen := make(map[U]int, len(collection)) + seenIndex := 0 + + for _, item := range collection { + key := iteratee(item) + + if index, ok := seen[key]; ok { + result[index] = item + continue + } + + seen[key] = seenIndex + seenIndex++ + result = append(result, item) + } + + return result +} diff --git a/slog/common_test.go b/slog/common_test.go new file mode 100644 index 000000000..1713965c5 --- /dev/null +++ b/slog/common_test.go @@ -0,0 +1,569 @@ +package sentryslog + +import ( + "context" + "log/slog" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSource(t *testing.T) { + // Simulate a runtime frame + pc, file, _, _ := runtime.Caller(0) + record := &slog.Record{PC: pc} + + // Call the source function + attr := source("sourceKey", record) + + // Assert the attributes + assert.Equal(t, "sourceKey", attr.Key) + assert.Equal(t, slog.KindGroup, attr.Value.Kind()) + + groupAttrs := attr.Value.Group() + + expectedAttrs := map[string]any{ + "function": "github.com/getsentry/sentry-go/slog.TestSource", + "file": file, + "line": int64(15), + } + + for _, a := range groupAttrs { + expectedValue, ok := expectedAttrs[a.Key] + if assert.True(t, ok, "unexpected attribute key: %s", a.Key) { + assert.Equal(t, expectedValue, a.Value.Any()) + } + } +} + +type testLogValuer struct { + name string + pass string +} + +func (t testLogValuer) LogValue() slog.Value { + return slog.GroupValue( + slog.String("name", t.name), + slog.String("password", "********"), + ) +} + +var stubLogValuer = testLogValuer{"userName", "password"} + +func TestReplaceAttrs(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // no ReplaceAttr func + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Int("int", 42)}, + replaceAttrs( + nil, + []string{"foobar"}, + slog.Bool("bool", true), slog.Int("int", 42), + ), + ) + + // no ReplaceAttr func, but convert struct with interface slog.LogValue in slog.Group + is.Equal( + []slog.Attr{slog.Group("user", slog.String("name", stubLogValuer.name), slog.String("password", "********"))}, + replaceAttrs( + nil, + []string{"foobar"}, + slog.Any("user", stubLogValuer), + ), + ) + + // ReplaceAttr func, but convert struct with interface slog.LogValue in slog.Group + is.Equal( + []slog.Attr{slog.Group("user", slog.String("name", stubLogValuer.name), slog.String("password", "********"))}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal([]string{"foobar", "user"}, groups) + return a + }, + []string{"foobar"}, + slog.Any("user", stubLogValuer), + ), + ) + + // ReplaceAttr func, but returns the same attributes + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Int("int", 42)}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal("foobar", groups[0]) + return a + }, + []string{"foobar"}, + slog.Bool("bool", true), slog.Int("int", 42), + ), + ) + + // Replace int and divide by 2 + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Int("int", 21)}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal("foobar", groups[0]) + if a.Value.Kind() == slog.KindInt64 { + a.Value = slog.Int64Value(a.Value.Int64() / 2) + } + return a + }, + []string{"foobar"}, + slog.Bool("bool", true), slog.Int("int", 42), + ), + ) + + // Remove int attr + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Any("int", nil)}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal("foobar", groups[0]) + if a.Value.Kind() == slog.KindInt64 { + return slog.Any("int", nil) + } + return a + }, + []string{"foobar"}, + slog.Bool("bool", true), slog.Int("int", 42), + ), + ) + + // Rename int attr + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Int("int2", 21)}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal("foobar", groups[0]) + if a.Value.Kind() == slog.KindInt64 { + return slog.Int("int2", 21) + } + return a + }, + []string{"foobar"}, + slog.Bool("bool", true), slog.Int("int", 42), + ), + ) + + // Rename attr in groups + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Group("group1", slog.Group("group2", slog.Int("int", 21)))}, + replaceAttrs( + func(groups []string, a slog.Attr) slog.Attr { + is.Equal("foobar", groups[0]) + if len(groups) > 1 { + is.Equal([]string{"foobar", "group1", "group2"}, groups) + return slog.Int("int", 21) + } + return a + }, + []string{"foobar"}, + slog.Bool("bool", true), slog.Group("group1", slog.Group("group2", slog.String("string", "foobar"))), + ), + ) +} + +func TestAttrsToMap(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // simple + is.EqualValues( + map[string]any{"key": "value"}, + attrsToMap(slog.Any("key", "value")), + ) + + // nested + is.EqualValues( + map[string]any{"key": "value", "key1": map[string]any{"key2": "value2"}}, + attrsToMap(slog.Any("key", "value"), slog.Group("key1", slog.Any("key2", "value2"))), + ) + + // merge + is.EqualValues( + map[string]any{"key": "value", "key1": map[string]any{"key2": "value2", "key3": "value3"}}, + attrsToMap( + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.Group("key1", slog.Any("key3", "value3")), + ), + ) +} + +func TestExtractError(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // not found + attrs, err := extractError( + []slog.Attr{ + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("foo", "bar"), + }, + ) + is.Len(attrs, 3) + is.Nil(err) + + // found key but wrong type + attrs, err = extractError( + []slog.Attr{ + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("error", "bar"), + }, + ) + is.Len(attrs, 3) + is.Nil(err) + + // found start first key + attrs, err = extractError( + []slog.Attr{ + slog.Any("error", assert.AnError), + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("foo", "bar"), + }, + ) + is.Len(attrs, 3) + is.EqualError(err, assert.AnError.Error()) + + // found start second key + attrs, err = extractError( + []slog.Attr{ + slog.Any("err", assert.AnError), + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("foo", "bar"), + }, + ) + is.Len(attrs, 3) + is.EqualError(err, assert.AnError.Error()) + + // found middle + attrs, err = extractError( + []slog.Attr{ + slog.Any("key", "value"), + slog.Any("error", assert.AnError), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("foo", "bar"), + }, + ) + is.Len(attrs, 3) + is.EqualError(err, assert.AnError.Error()) + + // found end + attrs, err = extractError( + []slog.Attr{ + slog.Any("key", "value"), + slog.Group("key1", slog.Any("key2", "value2")), + slog.String("foo", "bar"), + slog.Any("error", assert.AnError), + }, + ) + is.Len(attrs, 3) + is.EqualError(err, assert.AnError.Error()) +} + +func TestRemoveEmptyAttrs(t *testing.T) { + t.Parallel() + is := assert.New(t) + + // do not remove anything + is.Equal( + []slog.Attr{slog.Bool("bool", true), slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Bool("bool", true), slog.Int("int", 42)}, + ), + ) + is.Equal( + []slog.Attr{slog.Bool("bool", false), slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Bool("bool", false), slog.Int("int", 42)}, + ), + ) + + // remove if missing keys + is.Equal( + []slog.Attr{slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Bool("", true), slog.Int("int", 42)}, + ), + ) + + // remove if missing value + is.Equal( + []slog.Attr{slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Any("test", nil), slog.Int("int", 42)}, + ), + ) + is.Equal( + []slog.Attr{slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Group("test"), slog.Int("int", 42)}, + ), + ) + + // remove nested + is.Equal( + []slog.Attr{slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Any("test", nil), slog.Int("int", 42)}, + ), + ) + is.Equal( + []slog.Attr{slog.Int("int", 42)}, + removeEmptyAttrs( + []slog.Attr{slog.Group("test", slog.Any("foobar", nil)), slog.Int("int", 42)}, + ), + ) +} + +func TestValueToString(t *testing.T) { + tests := map[string]struct { + input slog.Attr + expected string + }{ + "KindInt64": { + input: slog.Int64("key", 42), + expected: "42", + }, + "KindUint64": { + input: slog.Uint64("key", 42), + expected: "42", + }, + "KindFloat64": { + input: slog.Float64("key", 3.14), + expected: "3.140000", + }, + "KindString": { + input: slog.String("key", "test"), + expected: "test", + }, + "KindBool": { + input: slog.Bool("key", true), + expected: "true", + }, + "KindDuration": { + input: slog.Duration("key", time.Second*42), + expected: "42s", + }, + "KindTime": { + input: slog.Time("key", time.Date(2023, time.July, 30, 12, 0, 0, 0, time.UTC)), + expected: "2023-07-30 12:00:00 +0000 UTC", + }, + "KindAny": { + input: slog.Any("key", "any value"), + expected: "any value", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := valueToString(tc.input.Value) + assert.Equal(t, tc.expected, actual) + }) + } +} + +type ctxKey string + +func TestContextExtractor(t *testing.T) { + tests := map[string]struct { + ctx context.Context + fns []func(ctx context.Context) []slog.Attr + expected []slog.Attr + }{ + "NoFunctions": { + ctx: context.Background(), + fns: []func(ctx context.Context) []slog.Attr{}, + expected: []slog.Attr{}, + }, + "SingleFunction": { + ctx: context.Background(), + fns: []func(ctx context.Context) []slog.Attr{ + func(ctx context.Context) []slog.Attr { + return []slog.Attr{slog.String("key1", "value1")} + }, + }, + expected: []slog.Attr{slog.String("key1", "value1")}, + }, + "MultipleFunctions": { + ctx: context.Background(), + fns: []func(ctx context.Context) []slog.Attr{ + func(ctx context.Context) []slog.Attr { + return []slog.Attr{slog.String("key1", "value1")} + }, + func(ctx context.Context) []slog.Attr { + return []slog.Attr{slog.String("key2", "value2")} + }, + }, + expected: []slog.Attr{slog.String("key1", "value1"), slog.String("key2", "value2")}, + }, + "FunctionWithContext": { + ctx: context.WithValue(context.Background(), ctxKey("userID"), "1234"), + fns: []func(ctx context.Context) []slog.Attr{ + func(ctx context.Context) []slog.Attr { + if userID, ok := ctx.Value(ctxKey("userID")).(string); ok { + return []slog.Attr{slog.String("userID", userID)} + } + return []slog.Attr{} + }, + }, + expected: []slog.Attr{slog.String("userID", "1234")}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := contextExtractor(tc.ctx, tc.fns) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestAppendAttrsToGroup(t *testing.T) { + tests := map[string]struct { + groups []string + actualAttrs []slog.Attr + newAttrs []slog.Attr + expected []slog.Attr + }{ + "NoGroups": { + groups: []string{}, + actualAttrs: []slog.Attr{slog.String("key1", "value1")}, + newAttrs: []slog.Attr{slog.String("key2", "value2")}, + expected: []slog.Attr{slog.String("key1", "value1"), slog.String("key2", "value2")}, + }, + "SingleGroup": { + groups: []string{"group1"}, + actualAttrs: []slog.Attr{ + slog.Group("group1", slog.String("key1", "value1")), + }, + newAttrs: []slog.Attr{slog.String("key2", "value2")}, + expected: []slog.Attr{ + slog.Group("group1", slog.String("key1", "value1"), slog.String("key2", "value2")), + }, + }, + "NestedGroups": { + groups: []string{"group1", "group2"}, + actualAttrs: []slog.Attr{ + slog.Group("group1", slog.Group("group2", slog.String("key1", "value1"))), + }, + newAttrs: []slog.Attr{slog.String("key2", "value2")}, + expected: []slog.Attr{ + slog.Group("group1", slog.Group("group2", slog.String("key1", "value1"), slog.String("key2", "value2"))), + }, + }, + "NewGroup": { + groups: []string{"group1"}, + actualAttrs: []slog.Attr{slog.String("key1", "value1")}, + newAttrs: []slog.Attr{slog.String("key2", "value2")}, + expected: []slog.Attr{ + slog.String("key1", "value1"), + slog.Group("group1", slog.String("key2", "value2")), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := appendAttrsToGroup(tc.groups, tc.actualAttrs, tc.newAttrs...) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestToAnySlice(t *testing.T) { + tests := map[string]struct { + input []slog.Attr + expected []any + }{ + "EmptySlice": { + input: []slog.Attr{}, + expected: []any{}, + }, + "SingleElement": { + input: []slog.Attr{slog.String("key1", "value1")}, + expected: []any{slog.String("key1", "value1")}, + }, + "MultipleElements": { + input: []slog.Attr{ + slog.String("key1", "value1"), + slog.Int("key2", 2), + slog.Bool("key3", true), + }, + expected: []any{ + slog.String("key1", "value1"), + slog.Int("key2", 2), + slog.Bool("key3", true), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := toAnySlice(tc.input) + assert.Equal(t, tc.expected, actual) + }) + } +} + +type textMarshalerExample struct { + Data string +} + +func (t textMarshalerExample) MarshalText() (text []byte, err error) { + return []byte(t.Data), nil +} + +type nonTextMarshalerExample struct { + Data string +} + +func TestAnyValueToString(t *testing.T) { + tests := map[string]struct { + input slog.Attr + expected string + }{ + "TextMarshaler implementation": { + input: slog.Any("key", textMarshalerExample{Data: "example"}), + expected: "example", + }, + "Non-TextMarshaler implementation": { + input: slog.Any("key", nonTextMarshalerExample{Data: "example"}), + expected: "{Data:example}", + }, + "String value": { + input: slog.String("key", "example string"), + expected: "example string", + }, + "Integer value": { + input: slog.Int("key", 42), + expected: "42", + }, + "Boolean value": { + input: slog.Bool("key", true), + expected: "true", + }, + "Nil value": { + input: slog.Any("key", nil), + expected: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + output := anyValueToString(tt.input.Value) + if output != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, output) + } + }) + } +} diff --git a/slog/converter.go b/slog/converter.go new file mode 100644 index 000000000..0e2fcd3e3 --- /dev/null +++ b/slog/converter.go @@ -0,0 +1,127 @@ +package sentryslog + +import ( + "encoding" + "fmt" + "log/slog" + "net/http" + + "github.com/getsentry/sentry-go" +) + +var ( + sourceKey = "source" + errorKeys = map[string]struct{}{ + "error": {}, + "err": {}, + } + name = "slog" +) + +type Converter func(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event + +func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, _ *sentry.Hub) *sentry.Event { + // aggregate all attributes + attrs := appendRecordAttrsToAttrs(loggerAttr, groups, record) + + // developer formatters + if addSource { + attrs = append(attrs, source(sourceKey, record)) + } + attrs = replaceAttrs(replaceAttr, []string{}, attrs...) + attrs = removeEmptyAttrs(attrs) + attrs, err := extractError(attrs) + + // handler formatter + event := sentry.NewEvent() + event.Timestamp = record.Time.UTC() + event.Level = LogLevels[record.Level] + event.Message = record.Message + event.Logger = name + event.SetException(err, 10) + + for i := range attrs { + attrToSentryEvent(attrs[i], event) + } + + return event +} + +func attrToSentryEvent(attr slog.Attr, event *sentry.Event) { + k := attr.Key + v := attr.Value + kind := v.Kind() + + switch { + case k == "dist" && kind == slog.KindString: + event.Dist = v.String() + case k == "environment" && kind == slog.KindString: + event.Environment = v.String() + case k == "event_id" && kind == slog.KindString: + event.EventID = sentry.EventID(v.String()) + case k == "platform" && kind == slog.KindString: + event.Platform = v.String() + case k == "release" && kind == slog.KindString: + event.Release = v.String() + case k == "server_name" && kind == slog.KindString: + event.ServerName = v.String() + case k == "tags" && kind == slog.KindGroup: + event.Tags = attrsToString(v.Group()...) + case k == "transaction" && kind == slog.KindString: + event.Transaction = v.String() + case k == "user" && kind == slog.KindGroup: + handleUserAttributes(v, event) + case k == "request" && kind == slog.KindAny: + handleRequestAttributes(v, event) + case kind == slog.KindGroup: + event.Extra[k] = attrsToMap(v.Group()...) + default: + event.Extra[k] = v.Any() + } +} + +func handleUserAttributes(v slog.Value, event *sentry.Event) { + data := attrsToString(v.Group()...) + if id, ok := data["id"]; ok { + event.User.ID = id + delete(data, "id") + } + if email, ok := data["email"]; ok { + event.User.Email = email + delete(data, "email") + } + if ipAddress, ok := data["ip_address"]; ok { + event.User.IPAddress = ipAddress + delete(data, "ip_address") + } + if username, ok := data["username"]; ok { + event.User.Username = username + delete(data, "username") + } + if name, ok := data["name"]; ok { + event.User.Name = name + delete(data, "name") + } + if segment, ok := data["segment"]; ok { + event.User.Segment = segment + delete(data, "segment") + } + event.User.Data = data +} + +func handleRequestAttributes(v slog.Value, event *sentry.Event) { + if req, ok := v.Any().(http.Request); ok { + event.Request = sentry.NewRequest(&req) + } else if req, ok := v.Any().(*http.Request); ok { + event.Request = sentry.NewRequest(req) + } else { + if tm, ok := v.Any().(encoding.TextMarshaler); ok { + data, err := tm.MarshalText() + if err == nil { + event.User.Data["request"] = string(data) + } else { + event.User.Data["request"] = fmt.Sprintf("%v", v.Any()) + } + } + } +} diff --git a/slog/converter_test.go b/slog/converter_test.go new file mode 100644 index 000000000..746ac714c --- /dev/null +++ b/slog/converter_test.go @@ -0,0 +1,188 @@ +package sentryslog + +import ( + "log/slog" + "net/http" + "net/url" + "testing" + "time" + + "github.com/getsentry/sentry-go" + "github.com/stretchr/testify/assert" +) + +func TestDefaultConverter(t *testing.T) { + // Mock data + mockAttr := slog.Attr{ + Key: "mockKey", + Value: slog.StringValue("mockValue"), + } + mockLoggerAttr := []slog.Attr{mockAttr} + mockGroups := []string{"group1", "group2"} + mockRecord := &slog.Record{ + Time: time.Now(), + Level: slog.LevelInfo, + Message: "Test message", + } + + // Mock replaceAttr function + replaceAttr := func(groups []string, a slog.Attr) slog.Attr { + return a + } + + // Call DefaultConverter function + event := DefaultConverter(true, replaceAttr, mockLoggerAttr, mockGroups, mockRecord, nil) + + // Assertions + assert.NotNil(t, event) + assert.Equal(t, mockRecord.Time.UTC(), event.Timestamp) + assert.Equal(t, LogLevels[mockRecord.Level], event.Level) + assert.Equal(t, mockRecord.Message, event.Message) + assert.Equal(t, name, event.Logger) + + // Check if the attributes are correctly converted + var foundMockKey bool + for key, value := range event.Extra { + if key == "mockKey" && value == "mockValue" { + foundMockKey = true + break + } + } + assert.True(t, foundMockKey) +} + +func TestAttrToSentryEvent(t *testing.T) { + reqURL, _ := url.Parse("http://example.com") + + tests := map[string]struct { + attr slog.Attr + expected *sentry.Event + }{ + "dist": { + attr: slog.Attr{Key: "dist", Value: slog.StringValue("dist_value")}, + expected: &sentry.Event{Dist: "dist_value"}, + }, + "environment": { + attr: slog.Attr{Key: "environment", Value: slog.StringValue("env_value")}, + expected: &sentry.Event{Environment: "env_value"}, + }, + "event_id": { + attr: slog.Attr{Key: "event_id", Value: slog.StringValue("event_id_value")}, + expected: &sentry.Event{EventID: sentry.EventID("event_id_value")}, + }, + "platform": { + attr: slog.Attr{Key: "platform", Value: slog.StringValue("platform_value")}, + expected: &sentry.Event{Platform: "platform_value"}, + }, + "release": { + attr: slog.Attr{Key: "release", Value: slog.StringValue("release_value")}, + expected: &sentry.Event{Release: "release_value"}, + }, + "server_name": { + attr: slog.Attr{Key: "server_name", Value: slog.StringValue("server_name_value")}, + expected: &sentry.Event{ServerName: "server_name_value"}, + }, + "tags": { + attr: slog.Attr{Key: "tags", Value: slog.GroupValue( + slog.Attr{Key: "tag1", Value: slog.StringValue("value1")}, + slog.Attr{Key: "tag2", Value: slog.StringValue("value2")}, + )}, + expected: &sentry.Event{Tags: map[string]string{"tag1": "value1", "tag2": "value2"}}, + }, + "transaction": { + attr: slog.Attr{Key: "transaction", Value: slog.StringValue("transaction_value")}, + expected: &sentry.Event{Transaction: "transaction_value"}, + }, + "user": { + attr: slog.Attr{Key: "user", Value: slog.GroupValue( + slog.Attr{Key: "id", Value: slog.StringValue("user_id")}, + slog.Attr{Key: "email", Value: slog.StringValue("user_email")}, + slog.Attr{Key: "ip_address", Value: slog.StringValue("user_ip_address")}, + slog.Attr{Key: "username", Value: slog.StringValue("user_username")}, + slog.Attr{Key: "segment", Value: slog.StringValue("user_segment")}, + slog.Attr{Key: "name", Value: slog.StringValue("user_name")}, + )}, + expected: &sentry.Event{ + User: sentry.User{ + ID: "user_id", + Email: "user_email", + IPAddress: "user_ip_address", + Username: "user_username", + Name: "user_name", + Segment: "user_segment", + Data: map[string]string{}, + }, + }, + }, + "request": { + attr: slog.Attr{Key: "request", Value: slog.AnyValue(&http.Request{ + Method: "GET", + URL: reqURL, + Header: http.Header{}, + })}, + expected: &sentry.Event{Request: &sentry.Request{ + Method: "GET", + URL: "http://", + Headers: map[string]string{ + "Host": "", + }, + }}, + }, + "request_ptr": { + attr: slog.Attr{Key: "request", Value: slog.AnyValue(http.Request{ + Method: "GET", + URL: reqURL, + Header: http.Header{}, + })}, + expected: &sentry.Event{Request: &sentry.Request{ + Method: "GET", + URL: "http://", + Headers: map[string]string{ + "Host": "", + }, + }}, + }, + "request_str": { + attr: slog.Attr{Key: "request", Value: slog.StringValue("GET http://")}, + expected: &sentry.Event{Extra: map[string]any{"request": "GET http://"}}, + }, + "context_group": { + attr: slog.Attr{Key: "context", Value: slog.GroupValue( + slog.Attr{Key: "key1", Value: slog.StringValue("value1")}, + slog.Attr{Key: "key2", Value: slog.StringValue("value2")}, + )}, + expected: &sentry.Event{Extra: map[string]any{ + "context": map[string]any{ + "key1": "value1", + "key2": "value2", + }}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + event := sentry.NewEvent() + attrToSentryEvent(tc.attr, event) + assert.Equal(t, tc.expected.Dist, event.Dist) + assert.Equal(t, tc.expected.Environment, event.Environment) + assert.Equal(t, tc.expected.EventID, event.EventID) + assert.Equal(t, tc.expected.Platform, event.Platform) + assert.Equal(t, tc.expected.Release, event.Release) + assert.Equal(t, tc.expected.ServerName, event.ServerName) + assert.Equal(t, tc.expected.Transaction, event.Transaction) + assert.Equal(t, tc.expected.User, event.User) + assert.Equal(t, tc.expected.Request, event.Request) + if len(tc.expected.Tags) == 0 { + assert.Empty(t, event.Tags) + } else { + assert.Equal(t, tc.expected.Tags, event.Tags) + } + if len(tc.expected.Extra) == 0 { + assert.Empty(t, event.Extra) + } else { + assert.Equal(t, tc.expected.Extra, event.Extra) + } + }) + } +} diff --git a/slog/go.mod b/slog/go.mod new file mode 100644 index 000000000..e99c26e63 --- /dev/null +++ b/slog/go.mod @@ -0,0 +1,16 @@ +module github.com/getsentry/sentry-go/slog + +go 1.21 + +require ( + github.com/getsentry/sentry-go v0.29.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/slog/go.sum b/slog/go.sum new file mode 100644 index 000000000..f2ee43849 --- /dev/null +++ b/slog/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getsentry/sentry-go v0.29.1 h1:DyZuChN8Hz3ARxGVV8ePaNXh1dQ7d76AiB117xcREwA= +github.com/getsentry/sentry-go v0.29.1/go.mod h1:x3AtIzN01d6SiWkderzaH28Tm0lgkafpJ5Bm3li39O0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/slog/sentryslog.go b/slog/sentryslog.go new file mode 100644 index 000000000..26538a6c1 --- /dev/null +++ b/slog/sentryslog.go @@ -0,0 +1,137 @@ +package sentryslog + +import ( + "context" + "log/slog" + + "github.com/getsentry/sentry-go" +) + +// Majority of the code in this package is derived from https://github.com/samber/slog-sentry AND https://github.com/samber/slog-common +// Smaller changes have been implemented to remove dependency on packages that are not available in the standard library of Go 1.18 +// MIT License + +// Copyright (c) 2023 Samuel Berthe + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +var ( + _ slog.Handler = (*SentryHandler)(nil) + + LogLevels = map[slog.Level]sentry.Level{ + slog.LevelDebug: sentry.LevelDebug, + slog.LevelInfo: sentry.LevelInfo, + slog.LevelWarn: sentry.LevelWarning, + slog.LevelError: sentry.LevelError, + } +) + +type Option struct { + // Level sets the minimum log level to capture and send to Sentry. + // Logs at this level and above will be processed. The default level is debug. + Level slog.Leveler + + // Hub specifies the Sentry Hub to use for capturing events. + // If not provided, the current Hub is used by default. + Hub *sentry.Hub + + // Converter is an optional function that customizes how log records + // are converted into Sentry events. By default, the DefaultConverter is used. + Converter Converter + + // AttrFromContext is an optional slice of functions that extract attributes + // from the context. These functions can add additional metadata to the log entry. + AttrFromContext []func(ctx context.Context) []slog.Attr + + // AddSource is an optional flag that, when set to true, includes the source + // information (such as file and line number) in the Sentry event. + // This can be useful for debugging purposes. + AddSource bool + + // ReplaceAttr is an optional function that allows for the modification or + // replacement of attributes in the log record. This can be used to filter + // or transform attributes before they are sent to Sentry. + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +func (o Option) NewSentryHandler() slog.Handler { + if o.Level == nil { + o.Level = slog.LevelDebug + } + + if o.Converter == nil { + o.Converter = DefaultConverter + } + + if o.AttrFromContext == nil { + o.AttrFromContext = []func(ctx context.Context) []slog.Attr{} + } + + return &SentryHandler{ + option: o, + attrs: []slog.Attr{}, + groups: []string{}, + } +} + +type SentryHandler struct { + option Option + attrs []slog.Attr + groups []string +} + +func (h *SentryHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.option.Level.Level() +} + +func (h *SentryHandler) Handle(ctx context.Context, record slog.Record) error { + hub := sentry.CurrentHub() + if hubFromContext := sentry.GetHubFromContext(ctx); hubFromContext != nil { + hub = hubFromContext + } else if h.option.Hub != nil { + hub = h.option.Hub + } + + fromContext := contextExtractor(ctx, h.option.AttrFromContext) + event := h.option.Converter(h.option.AddSource, h.option.ReplaceAttr, append(h.attrs, fromContext...), h.groups, &record, hub) + hub.CaptureEvent(event) + + return nil +} + +func (h *SentryHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &SentryHandler{ + option: h.option, + attrs: appendAttrsToGroup(h.groups, h.attrs, attrs...), + groups: h.groups, + } +} + +func (h *SentryHandler) WithGroup(name string) slog.Handler { + // https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247 + if name == "" { + return h + } + + return &SentryHandler{ + option: h.option, + attrs: h.attrs, + groups: append(h.groups, name), + } +} diff --git a/slog/sentryslog_test.go b/slog/sentryslog_test.go new file mode 100644 index 000000000..ecd910a51 --- /dev/null +++ b/slog/sentryslog_test.go @@ -0,0 +1,198 @@ +package sentryslog + +import ( + "context" + "fmt" + "log/slog" + "testing" + + "github.com/getsentry/sentry-go" +) + +func TestSentryHandler_Enabled(t *testing.T) { + tests := map[string]struct { + handlerLevel slog.Level + checkLevel slog.Level + expected bool + }{ + "LevelDebug, CheckDebug": { + handlerLevel: slog.LevelDebug, + checkLevel: slog.LevelDebug, + expected: true, + }, + "LevelInfo, CheckDebug": { + handlerLevel: slog.LevelInfo, + checkLevel: slog.LevelDebug, + expected: false, + }, + "LevelError, CheckWarn": { + handlerLevel: slog.LevelError, + checkLevel: slog.LevelWarn, + expected: false, + }, + "LevelWarn, CheckError": { + handlerLevel: slog.LevelWarn, + checkLevel: slog.LevelError, + expected: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + h := SentryHandler{option: Option{Level: tt.handlerLevel}} + if got := h.Enabled(context.Background(), tt.checkLevel); got != tt.expected { + t.Errorf("Enabled() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestSentryHandler_WithAttrs(t *testing.T) { + tests := map[string]struct { + initialAttrs []slog.Attr + newAttrs []slog.Attr + expected []slog.Attr + }{ + "Empty initial attrs": { + initialAttrs: []slog.Attr{}, + newAttrs: []slog.Attr{slog.String("key", "value")}, + expected: []slog.Attr{slog.String("key", "value")}, + }, + "Non-empty initial attrs": { + initialAttrs: []slog.Attr{slog.String("existing", "attr")}, + newAttrs: []slog.Attr{slog.String("key", "value")}, + expected: []slog.Attr{slog.String("existing", "attr"), slog.String("key", "value")}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + h := SentryHandler{attrs: tt.initialAttrs} + newHandler := h.WithAttrs(tt.newAttrs) + if !equalAttrs(newHandler.(*SentryHandler).attrs, tt.expected) { + t.Errorf("WithAttrs() = %+v, want %+v", newHandler.(*SentryHandler).attrs, tt.expected) + } + }) + } +} + +func TestSentryHandler_WithGroup(t *testing.T) { + tests := map[string]struct { + initialGroups []string + newGroup string + expected []string + }{ + "Empty initial groups": { + initialGroups: []string{}, + newGroup: "group1", + expected: []string{"group1"}, + }, + "Non-empty initial groups": { + initialGroups: []string{"existingGroup"}, + newGroup: "newGroup", + expected: []string{"existingGroup", "newGroup"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + h := SentryHandler{groups: tt.initialGroups} + newHandler := h.WithGroup(tt.newGroup) + if !equalStrings(newHandler.(*SentryHandler).groups, tt.expected) { + t.Errorf("WithGroup() = %+v, want %+v", newHandler.(*SentryHandler).groups, tt.expected) + } + }) + } +} + +func TestOption_NewSentryHandler(t *testing.T) { + tests := map[string]struct { + option Option + expected slog.Handler + }{ + "Default options": { + option: Option{}, + expected: &SentryHandler{option: Option{Level: slog.LevelDebug, Converter: DefaultConverter, AttrFromContext: []func(ctx context.Context) []slog.Attr{}}}, + }, + "Custom options": { + option: Option{ + Level: slog.LevelInfo, + Converter: CustomConverter, + AttrFromContext: []func(ctx context.Context) []slog.Attr{customAttrFromContext}, + }, + expected: &SentryHandler{ + option: Option{ + Level: slog.LevelInfo, + Converter: CustomConverter, + AttrFromContext: []func(ctx context.Context) []slog.Attr{customAttrFromContext}, + }, + attrs: []slog.Attr{}, + groups: []string{}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := tt.option.NewSentryHandler() + if !compareHandlers(got, tt.expected) { + t.Errorf("NewSentryHandler() = %+v, want %+v", got, tt.expected) + } + }) + } +} + +func equalAttrs(a, b []slog.Attr) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Key != b[i].Key || a[i].String() != b[i].String() { + return false + } + } + return true +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func compareHandlers(h1, h2 slog.Handler) bool { + sh1, ok1 := h1.(*SentryHandler) + sh2, ok2 := h2.(*SentryHandler) + if !ok1 || !ok2 { + return false + } + return sh1.option.Level == sh2.option.Level && + equalFuncs(sh1.option.AttrFromContext, sh2.option.AttrFromContext) +} + +func equalFuncs(a, b []func(ctx context.Context) []slog.Attr) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if fmt.Sprintf("%p", a[i]) != fmt.Sprintf("%p", b[i]) { + return false + } + } + return true +} + +// Mock functions for custom converter and custom attr from context. +func CustomConverter(bool, func([]string, slog.Attr) slog.Attr, []slog.Attr, []string, *slog.Record, *sentry.Hub) *sentry.Event { + return sentry.NewEvent() +} + +func customAttrFromContext(context.Context) []slog.Attr { + return []slog.Attr{slog.String("custom", "attr")} +}