Skip to content

fix: support optional backtrace fields and types #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 2, 2025
Merged
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
41 changes: 39 additions & 2 deletions internal/hbapi/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
package hbapi

import "time"
import (
"encoding/json"
"fmt"
"strconv"
"time"
)

// Number is a custom type that can unmarshal from either a string or integer JSON value
// and stores it as an integer
type Number int

// UnmarshalJSON implements json.Unmarshaler interface to handle both string and integer values
func (n *Number) UnmarshalJSON(data []byte) error {
// Try to unmarshal as integer first
var num int
if err := json.Unmarshal(data, &num); err == nil {
*n = Number(num)
return nil
}

// If that fails, try as string and parse to int
var str string
if err := json.Unmarshal(data, &str); err == nil {
// Try to parse the string as an integer
parsed, err := strconv.Atoi(str)
if err != nil {
return fmt.Errorf("Number: cannot parse string %q as integer: %v", str, err)
}
*n = Number(parsed)
return nil
}

return fmt.Errorf("Number: cannot unmarshal value into integer or string")
}

// User represents a Honeybadger user
type User struct {
Expand Down Expand Up @@ -95,9 +128,13 @@ type NoticeRequest struct {

// BacktraceEntry represents a single entry in the error backtrace
type BacktraceEntry struct {
Number string `json:"number"`
Number Number `json:"number"`
Column *Number `json:"column,omitempty"`
File string `json:"file"`
Method string `json:"method"`
Class string `json:"class,omitempty"`
Type string `json:"type,omitempty"`
Args []interface{} `json:"args,omitempty"`
Source map[string]interface{} `json:"source,omitempty"`
Context string `json:"context,omitempty"`
}
Expand Down
266 changes: 266 additions & 0 deletions internal/hbapi/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package hbapi

import (
"encoding/json"
"testing"
)

func TestNumber_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr bool
}{
{
name: "string value",
input: `"123"`,
want: 123,
wantErr: false,
},
{
name: "integer value",
input: `123`,
want: 123,
wantErr: false,
},
{
name: "zero integer",
input: `0`,
want: 0,
wantErr: false,
},
{
name: "negative integer",
input: `-1`,
want: -1,
wantErr: false,
},
{
name: "string negative integer",
input: `"-42"`,
want: -42,
wantErr: false,
},
{
name: "non-numeric string should fail",
input: `"abc"`,
wantErr: true,
},
{
name: "boolean value should fail",
input: `true`,
wantErr: true,
},
{
name: "object value should fail",
input: `{}`,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var n Number
err := json.Unmarshal([]byte(tt.input), &n)
if (err != nil) != tt.wantErr {
t.Errorf("Number.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && int(n) != tt.want {
t.Errorf("Number.UnmarshalJSON() = %v, want %v", n, tt.want)
}
})
}
}

func TestBacktraceEntry_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
want BacktraceEntry
wantErr bool
}{
{
name: "number as string",
input: `{
"number": "42",
"file": "/path/to/file.js",
"method": "someMethod"
}`,
want: BacktraceEntry{
Number: 42,
File: "/path/to/file.js",
Method: "someMethod",
},
wantErr: false,
},
{
name: "number as integer",
input: `{
"number": 42,
"file": "/path/to/file.js",
"method": "someMethod"
}`,
want: BacktraceEntry{
Number: 42,
File: "/path/to/file.js",
Method: "someMethod",
},
wantErr: false,
},
{
name: "complete backtrace entry with source",
input: `{
"number": 10,
"file": "/app/index.js",
"method": "handleError",
"source": {"10": "throw new Error();"},
"context": "app"
}`,
want: BacktraceEntry{
Number: 10,
File: "/app/index.js",
Method: "handleError",
Source: map[string]interface{}{"10": "throw new Error();"},
Context: "app",
},
wantErr: false,
},
{
name: "backtrace entry with all optional fields",
input: `{
"number": "25",
"column": 15,
"file": "/app/models/user.rb",
"method": "authenticate",
"class": "User",
"type": "instance",
"args": ["email@example.com", "password"],
"source": {"25": "user = User.find_by(email: email)"},
"context": "app"
}`,
want: BacktraceEntry{
Number: 25,
Column: numberPtr(15),
File: "/app/models/user.rb",
Method: "authenticate",
Class: "User",
Type: "instance",
Args: []interface{}{"email@example.com", "password"},
Source: map[string]interface{}{"25": "user = User.find_by(email: email)"},
Context: "app",
},
wantErr: false,
},
{
name: "backtrace entry with column as string",
input: `{
"number": 42,
"column": "8",
"file": "/lib/helper.js",
"method": "processData"
}`,
want: BacktraceEntry{
Number: 42,
Column: numberPtr(8),
File: "/lib/helper.js",
Method: "processData",
},
wantErr: false,
},
{
name: "number as string negative",
input: `{
"number": "-10",
"file": "/app/test.rb",
"method": "test"
}`,
want: BacktraceEntry{
Number: -10,
File: "/app/test.rb",
Method: "test",
},
wantErr: false,
},
{
name: "invalid string number",
input: `{
"number": "abc",
"file": "/app/test.rb",
"method": "test"
}`,
wantErr: true,
},
{
name: "number as boolean should fail",
input: `{
"number": true,
"file": "/app/test.rb",
"method": "test"
}`,
wantErr: true,
},
{
name: "column as boolean should fail",
input: `{
"number": 1,
"column": true,
"file": "/app/test.rb",
"method": "test"
}`,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var entry BacktraceEntry
err := json.Unmarshal([]byte(tt.input), &entry)
if (err != nil) != tt.wantErr {
t.Errorf("BacktraceEntry.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if int(entry.Number) != int(tt.want.Number) {
t.Errorf("BacktraceEntry.Number = %v, want %v", entry.Number, tt.want.Number)
}
if entry.File != tt.want.File {
t.Errorf("BacktraceEntry.File = %v, want %v", entry.File, tt.want.File)
}
if entry.Method != tt.want.Method {
t.Errorf("BacktraceEntry.Method = %v, want %v", entry.Method, tt.want.Method)
}
if entry.Context != tt.want.Context {
t.Errorf("BacktraceEntry.Context = %v, want %v", entry.Context, tt.want.Context)
}
// Check Column
if tt.want.Column != nil && entry.Column != nil {
if int(*entry.Column) != int(*tt.want.Column) {
t.Errorf("BacktraceEntry.Column = %v, want %v", *entry.Column, *tt.want.Column)
}
} else if (tt.want.Column == nil) != (entry.Column == nil) {
t.Errorf("BacktraceEntry.Column = %v, want %v", entry.Column, tt.want.Column)
}
// Check Class
if entry.Class != tt.want.Class {
t.Errorf("BacktraceEntry.Class = %v, want %v", entry.Class, tt.want.Class)
}
// Check Type
if entry.Type != tt.want.Type {
t.Errorf("BacktraceEntry.Type = %v, want %v", entry.Type, tt.want.Type)
}
// Check Args length
if len(entry.Args) != len(tt.want.Args) {
t.Errorf("BacktraceEntry.Args length = %v, want %v", len(entry.Args), len(tt.want.Args))
}
}
})
}
}

// Helper function to create Number pointers
func numberPtr(i int) *Number {
n := Number(i)
return &n
}
Loading