Skip to content

Commit c0d8bd8

Browse files
committed
fix(logsentry): replace removed Event.Extra with a typed log Context
sentry-go v0.46.0 removed Event.Extra (and Scope.SetExtra). Move the per-message key-value pairs into a named "log" Context (sentry.Context is map[string]any), keeping the writer on hub.CaptureEvent so Sentry Issues, grouping, stack traces and the original timestamp are preserved. Sentry's typed attribute API only attaches to Logs/Metrics, not Events, so it is not applicable here. Also format structured time.Time values via the configured Format (TimeFormat + Location), matching JSONWriter and TextWriter, instead of relying on sentry's default time.Time JSON marshaling. Other value types keep their native representation (numeric, bool, json.RawMessage) and nil is passed through as null. Add writer_test.go covering the Context contents and time formatting via a capturing sentry.Transport.
1 parent ce7d1c7 commit c0d8bd8

2 files changed

Lines changed: 160 additions & 6 deletions

File tree

logsentry/writer.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func (c *WriterConfig) FlushUnderlying() {
102102
// accumulates:
103103
// - Message text and timestamp
104104
// - Sentry level (mapped from golog level)
105-
// - Key-value pairs as Sentry event extra data
105+
// - Key-value pairs as a Sentry event "log" context
106106
// - Optional stack trace information
107107
//
108108
// The Writer automatically maps golog levels to Sentry levels:
@@ -113,7 +113,7 @@ func (c *WriterConfig) FlushUnderlying() {
113113
//
114114
// logger.Error("Database error").Str("query", sql).Err(err).Log()
115115
// // Creates Sentry event with level=ERROR, message="Database error",
116-
// // and extra data: {"query": sql, "error": err.Error()}
116+
// // and a "log" context: {"query": sql, "error": err.Error()}
117117
type Writer struct {
118118
config *WriterConfig
119119
timestamp time.Time
@@ -160,7 +160,7 @@ func (w *Writer) BeginMessage(config golog.Config, timestamp time.Time, level go
160160
// - The accumulated message text
161161
// - The mapped Sentry level
162162
// - The original timestamp
163-
// - All key-value pairs as extra data (from both config.extra and values)
163+
// - All key-value pairs as a "log" context (from both config.extra and values)
164164
// - Optional stack trace (if enabled in Sentry options)
165165
// - A fingerprint based on the message for grouping
166166
//
@@ -189,8 +189,14 @@ func (w *Writer) CommitMessage() {
189189
event.Level = w.level
190190
event.Message = w.message.String()
191191
event.Fingerprint = []string{event.Message}
192-
maps.Copy(event.Extra, w.config.extra)
193-
maps.Copy(event.Extra, w.values)
192+
// sentry-go v0.46.0 removed Event.Extra; attach the key-value pairs
193+
// as a named context instead (sentry.Context is map[string]any).
194+
logCtx := make(sentry.Context, len(w.config.extra)+len(w.values))
195+
maps.Copy(logCtx, w.config.extra)
196+
maps.Copy(logCtx, w.values)
197+
if len(logCtx) > 0 {
198+
event.Contexts["log"] = logCtx
199+
}
194200
if client := w.config.hub.Client(); client != nil && client.Options().AttachStacktrace {
195201
stackTrace := sentry.NewStacktrace()
196202
stackTrace.Frames = filterFrames(stackTrace.Frames)
@@ -278,7 +284,17 @@ func (w *Writer) WriteError(val error) {
278284
}
279285

280286
func (w *Writer) WriteTime(val time.Time) {
281-
w.writeVal(val)
287+
// Format the time using the configured Format (matching JSONWriter and
288+
// TextWriter) so structured time values honor Format.TimeFormat and
289+
// Format.Location instead of sentry's default time.Time JSON marshaling.
290+
format := w.config.format.TimeFormat
291+
if format == "" {
292+
format = golog.DefaultTimeFormat
293+
}
294+
if w.config.format.Location != nil {
295+
val = val.In(w.config.format.Location)
296+
}
297+
w.writeVal(val.Format(format))
282298
}
283299

284300
func (w *Writer) WriteUUID(val [16]byte) {

logsentry/writer_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package logsentry
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"math"
7+
"testing"
8+
"time"
9+
10+
"github.com/getsentry/sentry-go"
11+
12+
"github.com/domonda/golog"
13+
)
14+
15+
// captureTransport implements sentry.Transport and records every event that
16+
// would be sent, so tests can inspect the assembled *sentry.Event.
17+
type captureTransport struct {
18+
events []*sentry.Event
19+
}
20+
21+
func (t *captureTransport) Flush(time.Duration) bool { return true }
22+
func (t *captureTransport) FlushWithContext(context.Context) bool { return true }
23+
func (t *captureTransport) Configure(sentry.ClientOptions) {}
24+
func (t *captureTransport) SendEvent(event *sentry.Event) { t.events = append(t.events, event) }
25+
func (t *captureTransport) Close() {}
26+
27+
func newTestHub(t *testing.T, transport sentry.Transport) *sentry.Hub {
28+
t.Helper()
29+
client, err := sentry.NewClient(sentry.ClientOptions{
30+
// A custom Transport is always used regardless of the DSN; the DSN is
31+
// only set so events are not dropped before reaching the transport.
32+
Dsn: "https://public@example.com/1",
33+
Transport: transport,
34+
})
35+
if err != nil {
36+
t.Fatalf("sentry.NewClient: %v", err)
37+
}
38+
return sentry.NewHub(client, sentry.NewScope())
39+
}
40+
41+
// TestWriterContextValues verifies that golog typed values land in the
42+
// event's "log" Context with the expected types, that time.Time values are
43+
// formatted via the configured Format (TimeFormat + Location), and that nil
44+
// values are passed through as null.
45+
func TestWriterContextValues(t *testing.T) {
46+
transport := &captureTransport{}
47+
hub := newTestHub(t, transport)
48+
49+
format := golog.NewDefaultFormat()
50+
format.TimeFormat = "2006-01-02 15:04:05"
51+
format.Location = time.UTC
52+
53+
config := NewWriterConfig(hub, format, golog.AllLevelsActive, false, nil)
54+
logger := golog.NewLogger(golog.NewConfig(&golog.DefaultLevels, golog.AllLevelsActive, config))
55+
56+
// 14:30 in +02:00 == 12:30 UTC, exercising Format.Location conversion.
57+
ts := time.Date(2026, 6, 3, 14, 30, 0, 0, time.FixedZone("CEST", 2*60*60))
58+
id := [16]byte{0x85, 0x69, 0x2e, 0x8d, 0x49, 0xbf, 0x41, 0x50, 0xa1, 0x69, 0x6c, 0x2a, 0xdb, 0x93, 0x46, 0x3c}
59+
60+
logger.Error("boom").
61+
Str("query", "SELECT 1").
62+
Int("count", 42).
63+
Uint64("big", math.MaxUint64).
64+
Time("when", ts).
65+
UUID("id", id).
66+
Nil("missing").
67+
JSON("payload", []byte(`{"a":1}`)).
68+
Log()
69+
70+
hub.Flush(time.Second)
71+
72+
if len(transport.events) != 1 {
73+
t.Fatalf("expected 1 event, got %d", len(transport.events))
74+
}
75+
event := transport.events[0]
76+
if event.Message != "boom" {
77+
t.Errorf("event.Message = %q, want %q", event.Message, "boom")
78+
}
79+
80+
logCtx, ok := event.Contexts["log"]
81+
if !ok {
82+
t.Fatalf(`event.Contexts has no "log" key; contexts: %v`, event.Contexts)
83+
}
84+
85+
if got := logCtx["query"]; got != "SELECT 1" {
86+
t.Errorf(`log["query"] = %#v, want %q`, got, "SELECT 1")
87+
}
88+
if got := logCtx["count"]; got != int64(42) {
89+
t.Errorf(`log["count"] = %#v (%T), want int64(42)`, got, got)
90+
}
91+
if got := logCtx["big"]; got != uint64(math.MaxUint64) {
92+
t.Errorf(`log["big"] = %#v (%T), want uint64 max`, got, got)
93+
}
94+
if got := logCtx["when"]; got != "2026-06-03 12:30:00" {
95+
t.Errorf(`log["when"] = %#v, want %q`, got, "2026-06-03 12:30:00")
96+
}
97+
if got := logCtx["id"]; got != "85692e8d-49bf-4150-a169-6c2adb93463c" {
98+
t.Errorf(`log["id"] = %#v, want UUID string`, got)
99+
}
100+
if got, present := logCtx["missing"]; !present || got != nil {
101+
t.Errorf(`log["missing"] = %#v, present=%v; want nil and present`, got, present)
102+
}
103+
payload, ok := logCtx["payload"].(json.RawMessage)
104+
if !ok {
105+
t.Errorf(`log["payload"] = %#v (%T), want json.RawMessage`, logCtx["payload"], logCtx["payload"])
106+
} else if string(payload) != `{"a":1}` {
107+
t.Errorf(`log["payload"] = %s, want %s`, payload, `{"a":1}`)
108+
}
109+
}
110+
111+
// TestWriterTimeDefaultFormat verifies time formatting falls back to
112+
// golog.DefaultTimeFormat when Format.TimeFormat is empty, in the time's
113+
// original location when Format.Location is nil.
114+
func TestWriterTimeDefaultFormat(t *testing.T) {
115+
transport := &captureTransport{}
116+
hub := newTestHub(t, transport)
117+
118+
format := golog.NewDefaultFormat()
119+
format.TimeFormat = "" // force DefaultTimeFormat fallback
120+
format.Location = nil // keep original location
121+
122+
config := NewWriterConfig(hub, format, golog.AllLevelsActive, false, nil)
123+
logger := golog.NewLogger(golog.NewConfig(&golog.DefaultLevels, golog.AllLevelsActive, config))
124+
125+
loc := time.FixedZone("CEST", 2*60*60)
126+
ts := time.Date(2026, 6, 3, 14, 30, 0, 0, loc)
127+
128+
logger.Error("boom").Time("when", ts).Log()
129+
hub.Flush(time.Second)
130+
131+
if len(transport.events) != 1 {
132+
t.Fatalf("expected 1 event, got %d", len(transport.events))
133+
}
134+
want := ts.Format(golog.DefaultTimeFormat)
135+
if got := transport.events[0].Contexts["log"]["when"]; got != want {
136+
t.Errorf(`log["when"] = %#v, want %q`, got, want)
137+
}
138+
}

0 commit comments

Comments
 (0)