Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
## 1.7.0

**Breaking Changes:**
* `ExceptionInApi.Properties` type changed from `ExceptionInApiProperties` (struct) to `Properties` (map[string]interface{})
* Removed `ExceptionInApiProperties` struct, replaced with `Properties` map for custom properties.
* Note: All existing system properties (`$lib`, `$lib_version`, `distinct_id`, `$exception_list`, etc.) are still attached and sent to the API, only the type representation has changed.
Comment on lines +1 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we follow semver (Do we here?) this should be a major since it'll break people's apps compilation until they change the type

Copy link
Author

@jonathanlab jonathanlab Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like in CHANGELOG.md we previously used a minor version for a breaking changes release, so not sure if we're strictly following semver. Happy to bump to 2.0.0 if we want to start being more strict about it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, the versioning in Go got messed up at some point and people often release breaking changes as 1.X change.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo, until the sdk reaches feature parity with the other more mature ones (e.g. js sdk), breaking changes are okay without major version bump. The APIs in go sdk still leaves a lot to be desired (compared to the other ones). I'd assume a few other breaking changes like this might be necessary going forward.


**Features:**
* feat: Add custom properties support to exceptions
- Exceptions now support custom properties via the `Properties` field
- Added `Exception.WithProperties()` builder method to add properties to exceptions
- Added `WithPropertiesFn` option for slog handler to extract log attributes as exception properties
- Custom properties can override system properties

* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.12...v1.7.0)

## 1.6.12

* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.6.11...v1.6.12)
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ func main() {
"Error Description",
))

// Capture an exception with custom properties
client.Enqueue(posthog.NewDefaultException(
time.Now(),
"distinct-id",
"Error title",
"Error Description",
).WithProperties(posthog.NewProperties().
Set("custom_property_a", "custom_value_a").
Set("custom_property_b", "custom_value_b"),
))

// Or use the Exception struct directly for full control
client.Enqueue(posthog.Exception{
DistinctId: "distinct-id",
Properties: posthog.NewProperties().
Set("custom_property_a", "custom_value_a").
Set("custom_property_b", "custom_value_b"),
ExceptionList: []posthog.ExceptionItem{
{
Type: "Error title",
Value: "Error description",
},
},
})

// Create a logger which automatically captures warning logs and above
baseLogHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(posthog.NewSlogCaptureHandler(baseLogHandler, client,
Expand All @@ -86,7 +111,21 @@ func main() {
}),
})
logger.Warn("Log that something broke", "error", fmt.Errorf("this is a dummy scenario"))


// Optionally extract custom properties from slog attributes
loggerWithProps := slog.New(posthog.NewSlogCaptureHandler(baseLogHandler, client,
posthog.WithPropertiesFn(func(ctx context.Context, r slog.Record) posthog.Properties {
props := posthog.NewProperties()
r.Attrs(func(a slog.Attr) bool {
props.Set(a.Key, a.Value.Any())
return true
})
return props
}),
))
loggerWithProps.Error("Payment failed", "payment_id", "pay_123", "amount", 99.99)
// This exception will have custom properties: payment_id and amount

// Capture event with calculated uuid to deduplicate repeated events.
// The library github.com/google/uuid is used
key := myEvent.Id + myEvent.Project
Expand Down
856 changes: 856 additions & 0 deletions coverage.out

Large diffs are not rendered by default.

59 changes: 35 additions & 24 deletions error_tracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Exception struct {

DistinctId string
Timestamp time.Time
Properties Properties
DisableGeoIP bool

// Typed properties that end up in the API "properties" object:
Expand Down Expand Up @@ -51,21 +52,12 @@ type StackFrame struct {
}

type ExceptionInApi struct {
Type string `json:"type"`
Library string `json:"library"`
LibraryVersion string `json:"library_version"`
Timestamp time.Time `json:"timestamp"`
Event string `json:"event"`
Properties ExceptionInApiProperties `json:"properties"`
}

type ExceptionInApiProperties struct {
Lib string `json:"$lib"`
LibVersion string `json:"$lib_version"`
DistinctId string `json:"distinct_id"`
DisableGeoIP bool `json:"$geoip_disable,omitempty"`
ExceptionList []ExceptionItem `json:"$exception_list"`
ExceptionFingerprint *string `json:"$exception_fingerprint,omitempty"`
Type string `json:"type"`
Library string `json:"library"`
LibraryVersion string `json:"library_version"`
Timestamp time.Time `json:"timestamp"`
Event string `json:"event"`
Properties Properties `json:"properties"`
}

func (msg Exception) internal() { panic(unimplementedError) }
Expand Down Expand Up @@ -116,25 +108,39 @@ func (msg ExceptionItem) Validate() error {
func (msg Exception) APIfy() APIMessage {
libVersion := getVersion()

properties := Properties{}.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, wouldn't it be better to re-use msg.Properties instead of creating a new one here?

Copy link
Author

@jonathanlab jonathanlab Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't do that unfortunately, because that'd mutate the caller's msg.Properties (preferably no side-effects happen), so we create a new one instead

Set("$lib", SDKName).
Set("$lib_version", libVersion).
Set("distinct_id", msg.DistinctId).
Set("$exception_list", msg.ExceptionList)

if msg.DisableGeoIP {
properties.Set("$geoip_disable", true)
}

if msg.ExceptionFingerprint != nil {
properties.Set("$exception_fingerprint", msg.ExceptionFingerprint)
}

if msg.Properties != nil {
for k, v := range msg.Properties {
properties[k] = v
}
}

return ExceptionInApi{
Type: msg.Type, // set to "exception" by Enqueue switch
Type: msg.Type,
Event: "$exception",
Library: SDKName,
LibraryVersion: libVersion,
Timestamp: msg.Timestamp,
Properties: ExceptionInApiProperties{
Lib: SDKName,
LibVersion: libVersion,
DistinctId: msg.DistinctId,
DisableGeoIP: msg.DisableGeoIP,
ExceptionList: msg.ExceptionList,
ExceptionFingerprint: msg.ExceptionFingerprint,
},
Properties: properties,
}
}

// NewDefaultException is a convenience function to build an Exception object (usable for `client.Enqueue`)
// with sane defaults. If you want more control, please manually build the Exception object.
// Use .WithProperties() to add custom properties to the exception.
func NewDefaultException(
timestamp time.Time,
distinctID, title, description string,
Expand All @@ -153,3 +159,8 @@ func NewDefaultException(
},
}
}

func (e Exception) WithProperties(properties Properties) Exception {
e.Properties = properties
return e
}
1 change: 1 addition & 0 deletions error_tracking_slog.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (h *SlogCaptureHandler) Handle(ctx context.Context, r slog.Record) error {
},
},
ExceptionFingerprint: h.cfg.fingerprint(ctx, r),
Properties: h.cfg.properties(ctx, r),
}
_ = h.client.Enqueue(ex) // ignore enqueue error to keep logging safe

Expand Down
12 changes: 12 additions & 0 deletions error_tracking_slog_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type captureConfig struct {
// record for fields like "err" or "error" and uses the extracted
// error message as the description.
descriptionExtractor DescriptionExtractor

// event properties to attach to the captured exception event.
properties func(ctx context.Context, r slog.Record) Properties
}

func defaultCaptureConfig() captureConfig {
Expand All @@ -55,6 +58,9 @@ func defaultCaptureConfig() captureConfig {
ErrorKeys: []string{"err", "error"},
Fallback: "<no linked error>",
},
properties: func(ctx context.Context, r slog.Record) Properties {
return NewProperties()
Copy link
Contributor

@vdekrijger vdekrijger Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but is it worth considering having something like this (shamelessly taken from the unit test) as default behavior?

Suggested change
return NewProperties()
props := NewProperties()
r.Attrs(func(a slog.Attr) bool {
props.Set(a.Key, a.Value.Any())
return true
})
return props

I figure that most users will likely do something like this as their default behaviour, and if they don't they can override it through WithPropertiesFn.

},
}
}

Expand Down Expand Up @@ -87,3 +93,9 @@ func WithStackTraceExtractor(extractor StackTraceExtractor) SlogOption {
func WithDescriptionExtractor(extractor DescriptionExtractor) SlogOption {
return func(c *captureConfig) { c.descriptionExtractor = extractor }
}

// WithPropertiesFn sets a custom function to extract properties from slog records.
// This allows you to attach custom metadata from log records to exception events.
func WithPropertiesFn(fn func(ctx context.Context, r slog.Record) Properties) SlogOption {
return func(c *captureConfig) { c.properties = fn }
}
55 changes: 55 additions & 0 deletions error_tracking_slog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,58 @@ func TestErrorExtractor_ExtractDescription(t *testing.T) {
})
}
}

func TestSlogCaptureHandler_WithPropertiesFn(t *testing.T) {
next := &fakeNextSlogHandler{isEnabled: true}
client := &fakeEnqueueClient{}
ctx := context.Background()

handler := NewSlogCaptureHandler(next, client,
WithMinCaptureLevel(slog.LevelWarn),
WithDistinctIDFn(func(_ context.Context, _ slog.Record) string {
return "test-user"
}),
WithPropertiesFn(func(_ context.Context, r slog.Record) Properties {
props := NewProperties()
r.Attrs(func(a slog.Attr) bool {
props.Set(a.Key, a.Value.Any())
return true
})
return props
}),
)

record := createLogRecord(slog.LevelError, "test error",
slog.String("environment", "production"),
slog.Int("retry_count", 3),
)

if err := handler.Handle(ctx, record); err != nil {
t.Fatalf("Handle returned error: %v", err)
}

if len(client.enqueuedMsgs) != 1 {
t.Fatalf("expected 1 enqueued message, got %d", len(client.enqueuedMsgs))
}

exception, ok := client.enqueuedMsgs[0].(Exception)
if !ok {
t.Fatalf("expected Exception, got %T", client.enqueuedMsgs[0])
}

if exception.Properties == nil {
t.Fatal("expected Properties to be set")
}

expectedProps := map[string]interface{}{
"environment": "production",
"retry_count": int64(3), // note: slog.Int converts to int64
}

for key, expected := range expectedProps {
if exception.Properties[key] != expected {
t.Errorf("property %s: expected %v (type %T), got %v (type %T)",
key, expected, expected, exception.Properties[key], exception.Properties[key])
}
}
}
Loading
Loading