diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..338962b --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Debug +debug +*.log diff --git a/README.md b/README.md index 3e44a4f..7c1b32d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,308 @@ -# irontelemetry-go -IronTelemetry SDK for Go - Error monitoring and crash reporting +# IronTelemetry SDK for Go + +Error monitoring and crash reporting SDK for Go applications. Capture exceptions, track user journeys, and get insights to fix issues faster. + +[![Go Reference](https://pkg.go.dev/badge/github.com/IronServices/irontelemetry-go.svg)](https://pkg.go.dev/github.com/IronServices/irontelemetry-go) +[![Go Report Card](https://goreportcard.com/badge/github.com/IronServices/irontelemetry-go)](https://goreportcard.com/report/github.com/IronServices/irontelemetry-go) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +go get github.com/IronServices/irontelemetry-go +``` + +## Quick Start + +### Basic Exception Capture + +```go +package main + +import ( + "errors" + "log" + + irontelemetry "github.com/IronServices/irontelemetry-go" +) + +func main() { + // Initialize with your DSN + client, err := irontelemetry.New(irontelemetry.Options{ + DSN: "https://pk_live_xxx@irontelemetry.com", + }) + if err != nil { + log.Fatal(err) + } + defer client.Close() + + // Capture exceptions + if err := doSomething(); err != nil { + client.CaptureException(err) + } +} + +func doSomething() error { + return errors.New("something went wrong") +} +``` + +### Journey Tracking + +Track user journeys to understand the context of errors: + +```go +package main + +import ( + irontelemetry "github.com/IronServices/irontelemetry-go" +) + +func main() { + client, _ := irontelemetry.New(irontelemetry.Options{ + DSN: "https://pk_live_xxx@irontelemetry.com", + }) + defer client.Close() + + // Start a journey + journey := client.StartJourney("Checkout Flow") + journey.SetUser("user-123", "user@example.com", "John Doe") + + // Track steps + step := journey.StartStep("Validate Cart", irontelemetry.CategoryBusiness) + if err := validateCart(); err != nil { + step.Fail(err) + journey.Fail(err) + client.CaptureException(err) + return + } + step.Complete() + + step = journey.StartStep("Process Payment", irontelemetry.CategoryBusiness) + if err := processPayment(); err != nil { + step.Fail(err) + journey.Fail(err) + client.CaptureException(err) + return + } + step.Complete() + + journey.Complete() +} +``` + +Or use the helper method: + +```go +journey := client.StartJourney("Checkout Flow") + +err := journey.RunStep("Validate Cart", irontelemetry.CategoryBusiness, func() error { + return validateCart() +}) +if err != nil { + journey.Fail(err) + client.CaptureException(err) + return +} + +err = journey.RunStep("Process Payment", irontelemetry.CategoryBusiness, func() error { + return processPayment() +}) +if err != nil { + journey.Fail(err) + client.CaptureException(err) + return +} + +journey.Complete() +``` + +## Configuration + +```go +client, err := irontelemetry.New(irontelemetry.Options{ + DSN: "https://pk_live_xxx@irontelemetry.com", + Environment: "production", + AppVersion: "1.2.3", + SampleRate: 1.0, // 100% of events + Debug: false, + BeforeSend: func(event *irontelemetry.TelemetryEvent) *irontelemetry.TelemetryEvent { + // Filter or modify events + if strings.Contains(event.Message, "expected") { + return nil // Drop the event + } + return event + }, +}) +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `DSN` | string | required | Your Data Source Name | +| `Environment` | string | "production" | Environment name | +| `AppVersion` | string | "0.0.0" | Application version | +| `SampleRate` | float64 | 1.0 | Sample rate (0.0 to 1.0) | +| `MaxBreadcrumbs` | int | 100 | Max breadcrumbs to keep | +| `Debug` | bool | false | Enable debug logging | +| `BeforeSend` | func | nil | Hook to filter/modify events | +| `EnableOfflineQueue` | bool | true | Enable offline queue | +| `MaxOfflineQueueSize` | int | 500 | Max offline queue size | + +## Features + +- **Automatic Stack Traces**: Full stack traces captured with every exception +- **Journey Tracking**: Track user flows and correlate errors with context +- **Breadcrumbs**: Leave a trail of events leading up to an error +- **User Context**: Associate errors with specific users +- **Tags & Extras**: Add custom metadata to your events +- **Context Support**: Full context.Context support for cancellation +- **Thread-Safe**: All operations are safe for concurrent use + +## Breadcrumbs + +```go +// Add simple breadcrumbs +client.AddBreadcrumb("User clicked checkout button", irontelemetry.CategoryUI) +client.AddBreadcrumb("Payment API called", irontelemetry.CategoryHTTP) + +// With level +client.AddBreadcrumbWithLevel( + "User logged in", + irontelemetry.CategoryAuth, + irontelemetry.SeverityInfo, +) + +// With data +client.AddBreadcrumbWithData( + "API request completed", + irontelemetry.CategoryHTTP, + irontelemetry.SeverityInfo, + map[string]any{ + "url": "/api/checkout", + "statusCode": 200, + "duration": 150, + }, +) +``` + +### Breadcrumb Categories + +```go +irontelemetry.CategoryUI // User interface interactions +irontelemetry.CategoryHTTP // HTTP requests +irontelemetry.CategoryNavigation // Page/route navigation +irontelemetry.CategoryConsole // Console output +irontelemetry.CategoryAuth // Authentication events +irontelemetry.CategoryBusiness // Business logic events +irontelemetry.CategoryNotification // Notification events +irontelemetry.CategoryCustom // Custom events +``` + +## Severity Levels + +```go +irontelemetry.SeverityDebug +irontelemetry.SeverityInfo +irontelemetry.SeverityWarning +irontelemetry.SeverityError +irontelemetry.SeverityFatal +``` + +## User Context + +```go +// Simple user ID +client.SetUserByID("user-123") + +// With email +client.SetUserWithEmail("user-123", "user@example.com") + +// Full user object +client.SetUser(&irontelemetry.User{ + ID: "user-123", + Email: "user@example.com", + Name: "John Doe", + Data: map[string]any{ + "plan": "premium", + }, +}) +``` + +## Tags and Extra Data + +```go +// Set individual tags +client.SetTag("release", "v1.2.3") +client.SetTag("server", "prod-1") + +// Set multiple tags +client.SetTags(map[string]string{ + "release": "v1.2.3", + "server": "prod-1", +}) + +// Set extra data +client.SetExtra("request_id", "abc-123") +client.SetExtras(map[string]any{ + "request_id": "abc-123", + "user_agent": "Mozilla/5.0...", +}) +``` + +## Context Support + +All capture methods support context for cancellation: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +result := client.CaptureExceptionWithContext(ctx, err) +if !result.Success { + log.Printf("Failed to send event: %s", result.Error) +} +``` + +## HTTP Middleware Example + +```go +func TelemetryMiddleware(client *irontelemetry.Client) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client.AddBreadcrumbWithData( + "HTTP Request", + irontelemetry.CategoryHTTP, + irontelemetry.SeverityInfo, + map[string]any{ + "method": r.Method, + "url": r.URL.String(), + }, + ) + + defer func() { + if err := recover(); err != nil { + client.CaptureException(fmt.Errorf("panic: %v", err)) + panic(err) // Re-panic + } + }() + + next.ServeHTTP(w, r) + }) + } +} +``` + +## Requirements + +- Go 1.21+ + +## Links + +- [Documentation](https://www.irontelemetry.com/docs) +- [Dashboard](https://www.irontelemetry.com) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/breadcrumbs.go b/breadcrumbs.go new file mode 100644 index 0000000..c954b49 --- /dev/null +++ b/breadcrumbs.go @@ -0,0 +1,96 @@ +package irontelemetry + +import ( + "sync" + "time" +) + +// BreadcrumbManager manages a ring buffer of breadcrumbs +type BreadcrumbManager struct { + mu sync.RWMutex + breadcrumbs []Breadcrumb + maxBreadcrumbs int +} + +// NewBreadcrumbManager creates a new BreadcrumbManager +func NewBreadcrumbManager(maxBreadcrumbs int) *BreadcrumbManager { + return &BreadcrumbManager{ + breadcrumbs: make([]Breadcrumb, 0, maxBreadcrumbs), + maxBreadcrumbs: maxBreadcrumbs, + } +} + +// Add adds a breadcrumb to the ring buffer +func (bm *BreadcrumbManager) Add(breadcrumb Breadcrumb) { + bm.mu.Lock() + defer bm.mu.Unlock() + + if breadcrumb.Timestamp.IsZero() { + breadcrumb.Timestamp = time.Now() + } + + if len(bm.breadcrumbs) >= bm.maxBreadcrumbs { + // Shift elements to make room (ring buffer behavior) + copy(bm.breadcrumbs, bm.breadcrumbs[1:]) + bm.breadcrumbs = bm.breadcrumbs[:len(bm.breadcrumbs)-1] + } + + bm.breadcrumbs = append(bm.breadcrumbs, breadcrumb) +} + +// AddSimple adds a simple breadcrumb with just a message and category +func (bm *BreadcrumbManager) AddSimple(message string, category BreadcrumbCategory) { + bm.Add(Breadcrumb{ + Timestamp: time.Now(), + Category: category, + Message: message, + Level: SeverityInfo, + }) +} + +// AddWithLevel adds a breadcrumb with a specific level +func (bm *BreadcrumbManager) AddWithLevel(message string, category BreadcrumbCategory, level SeverityLevel) { + bm.Add(Breadcrumb{ + Timestamp: time.Now(), + Category: category, + Message: message, + Level: level, + }) +} + +// AddWithData adds a breadcrumb with additional data +func (bm *BreadcrumbManager) AddWithData(message string, category BreadcrumbCategory, level SeverityLevel, data map[string]any) { + bm.Add(Breadcrumb{ + Timestamp: time.Now(), + Category: category, + Message: message, + Level: level, + Data: data, + }) +} + +// GetAll returns a copy of all breadcrumbs +func (bm *BreadcrumbManager) GetAll() []Breadcrumb { + bm.mu.RLock() + defer bm.mu.RUnlock() + + result := make([]Breadcrumb, len(bm.breadcrumbs)) + copy(result, bm.breadcrumbs) + return result +} + +// Clear removes all breadcrumbs +func (bm *BreadcrumbManager) Clear() { + bm.mu.Lock() + defer bm.mu.Unlock() + + bm.breadcrumbs = make([]Breadcrumb, 0, bm.maxBreadcrumbs) +} + +// Count returns the number of breadcrumbs +func (bm *BreadcrumbManager) Count() int { + bm.mu.RLock() + defer bm.mu.RUnlock() + + return len(bm.breadcrumbs) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..3c57fbd --- /dev/null +++ b/client.go @@ -0,0 +1,284 @@ +package irontelemetry + +import ( + "context" + "fmt" + "math/rand" + "runtime" + "sync" + "time" +) + +// Client is the main IronTelemetry client +type Client struct { + mu sync.RWMutex + opts Options + parsedDSN *ParsedDSN + transport *Transport + breadcrumbs *BreadcrumbManager + journeys *JourneyManager + user *User + tags map[string]string + extra map[string]any + initialized bool +} + +// New creates a new IronTelemetry client +func New(opts Options) (*Client, error) { + resolvedOpts, parsedDSN, err := ResolveOptions(opts) + if err != nil { + return nil, err + } + + client := &Client{ + opts: resolvedOpts, + parsedDSN: parsedDSN, + transport: NewTransport(parsedDSN, resolvedOpts.APIBaseURL, resolvedOpts.Debug), + breadcrumbs: NewBreadcrumbManager(resolvedOpts.MaxBreadcrumbs), + tags: make(map[string]string), + extra: make(map[string]any), + initialized: true, + } + client.journeys = NewJourneyManager(client) + + if opts.Debug { + fmt.Printf("[IronTelemetry] Initialized with DSN: %s\n", parsedDSN.APIBaseURL) + } + + return client, nil +} + +// CaptureException captures an error and sends it to the server +func (c *Client) CaptureException(err error) SendResult { + return c.CaptureExceptionWithContext(context.Background(), err) +} + +// CaptureExceptionWithContext captures an error with context +func (c *Client) CaptureExceptionWithContext(ctx context.Context, err error) SendResult { + if err == nil { + return SendResult{Success: false, Error: "nil error"} + } + + event := c.createEvent(SeverityError, err.Error()) + event.Exception = &ExceptionInfo{ + Type: fmt.Sprintf("%T", err), + Message: err.Error(), + Stacktrace: captureStacktrace(3), + } + + return c.sendEvent(ctx, event) +} + +// CaptureMessage captures a message and sends it to the server +func (c *Client) CaptureMessage(message string, level SeverityLevel) SendResult { + return c.CaptureMessageWithContext(context.Background(), message, level) +} + +// CaptureMessageWithContext captures a message with context +func (c *Client) CaptureMessageWithContext(ctx context.Context, message string, level SeverityLevel) SendResult { + event := c.createEvent(level, message) + return c.sendEvent(ctx, event) +} + +// AddBreadcrumb adds a breadcrumb +func (c *Client) AddBreadcrumb(message string, category BreadcrumbCategory) { + c.breadcrumbs.AddSimple(message, category) +} + +// AddBreadcrumbWithLevel adds a breadcrumb with a level +func (c *Client) AddBreadcrumbWithLevel(message string, category BreadcrumbCategory, level SeverityLevel) { + c.breadcrumbs.AddWithLevel(message, category, level) +} + +// AddBreadcrumbWithData adds a breadcrumb with data +func (c *Client) AddBreadcrumbWithData(message string, category BreadcrumbCategory, level SeverityLevel, data map[string]any) { + c.breadcrumbs.AddWithData(message, category, level, data) +} + +// SetUser sets the user context +func (c *Client) SetUser(user *User) { + c.mu.Lock() + defer c.mu.Unlock() + c.user = user +} + +// SetUserByID sets the user context by ID +func (c *Client) SetUserByID(id string) { + c.SetUser(&User{ID: id}) +} + +// SetUserWithEmail sets the user context with ID and email +func (c *Client) SetUserWithEmail(id, email string) { + c.SetUser(&User{ID: id, Email: email}) +} + +// SetTag sets a tag +func (c *Client) SetTag(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + c.tags[key] = value +} + +// SetTags sets multiple tags +func (c *Client) SetTags(tags map[string]string) { + c.mu.Lock() + defer c.mu.Unlock() + for k, v := range tags { + c.tags[k] = v + } +} + +// SetExtra sets extra data +func (c *Client) SetExtra(key string, value any) { + c.mu.Lock() + defer c.mu.Unlock() + c.extra[key] = value +} + +// SetExtras sets multiple extra data values +func (c *Client) SetExtras(extras map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + for k, v := range extras { + c.extra[k] = v + } +} + +// StartJourney starts a new journey +func (c *Client) StartJourney(name string) *Journey { + return c.journeys.StartJourney(name) +} + +// GetCurrentJourney returns the current journey context +func (c *Client) GetCurrentJourney() *JourneyContext { + return c.journeys.GetCurrent() +} + +// ClearBreadcrumbs clears all breadcrumbs +func (c *Client) ClearBreadcrumbs() { + c.breadcrumbs.Clear() +} + +// Flush flushes any pending events (placeholder for offline queue) +func (c *Client) Flush() { + // Future: Implement offline queue flushing +} + +// Close closes the client and flushes pending events +func (c *Client) Close() { + c.Flush() +} + +func (c *Client) createEvent(level SeverityLevel, message string) *TelemetryEvent { + c.mu.RLock() + defer c.mu.RUnlock() + + // Copy tags + tags := make(map[string]string, len(c.tags)) + for k, v := range c.tags { + tags[k] = v + } + + // Copy extra + extra := make(map[string]any, len(c.extra)) + for k, v := range c.extra { + extra[k] = v + } + + event := &TelemetryEvent{ + EventID: GenerateEventID(), + Timestamp: time.Now(), + Level: level, + Message: message, + Tags: tags, + Extra: extra, + Breadcrumbs: c.breadcrumbs.GetAll(), + Environment: c.opts.Environment, + AppVersion: c.opts.AppVersion, + Platform: PlatformInfo{ + Name: "go", + Version: runtime.Version(), + OS: runtime.GOOS + "/" + runtime.GOARCH, + }, + } + + // Copy user if set + if c.user != nil { + event.User = &User{ + ID: c.user.ID, + Email: c.user.Email, + Name: c.user.Name, + } + if c.user.Data != nil { + event.User.Data = make(map[string]any, len(c.user.Data)) + for k, v := range c.user.Data { + event.User.Data[k] = v + } + } + } + + // Copy journey if active + if journey := c.journeys.GetCurrent(); journey != nil { + event.Journey = &JourneyContext{ + JourneyID: journey.JourneyID, + Name: journey.Name, + CurrentStep: journey.CurrentStep, + StartedAt: journey.StartedAt, + Metadata: make(map[string]any, len(journey.Metadata)), + } + for k, v := range journey.Metadata { + event.Journey.Metadata[k] = v + } + } + + return event +} + +func (c *Client) sendEvent(ctx context.Context, event *TelemetryEvent) SendResult { + // Check sample rate + if c.opts.SampleRate < 1.0 && rand.Float64() > c.opts.SampleRate { + if c.opts.Debug { + fmt.Printf("[IronTelemetry] Event sampled out: %s\n", event.EventID) + } + return SendResult{Success: true, EventID: event.EventID} + } + + // Apply beforeSend hook + if c.opts.BeforeSend != nil { + event = c.opts.BeforeSend(event) + if event == nil { + if c.opts.Debug { + fmt.Println("[IronTelemetry] Event dropped by beforeSend hook") + } + return SendResult{Success: true} + } + } + + return c.transport.Send(ctx, event) +} + +func captureStacktrace(skip int) []StackFrame { + var frames []StackFrame + pcs := make([]uintptr, 32) + n := runtime.Callers(skip, pcs) + if n == 0 { + return frames + } + + pcs = pcs[:n] + runtimeFrames := runtime.CallersFrames(pcs) + + for { + frame, more := runtimeFrames.Next() + frames = append(frames, StackFrame{ + Function: frame.Function, + Filename: frame.File, + Lineno: frame.Line, + }) + if !more { + break + } + } + + return frames +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..8ac5c9d --- /dev/null +++ b/config.go @@ -0,0 +1,72 @@ +package irontelemetry + +import ( + "errors" + "net/url" + "strings" + + "github.com/google/uuid" +) + +// ParseDSN parses a DSN string into its components +// Format: https://pk_live_xxx@irontelemetry.com +func ParseDSN(dsn string) (*ParsedDSN, error) { + parsed, err := url.Parse(dsn) + if err != nil { + return nil, errors.New("invalid DSN format: " + dsn) + } + + publicKey := parsed.User.Username() + if publicKey == "" || !strings.HasPrefix(publicKey, "pk_") { + return nil, errors.New("DSN must contain a valid public key starting with pk_") + } + + return &ParsedDSN{ + PublicKey: publicKey, + Host: parsed.Host, + Protocol: parsed.Scheme, + APIBaseURL: parsed.Scheme + "://" + parsed.Host, + }, nil +} + +// GenerateEventID generates a unique event ID +func GenerateEventID() string { + return uuid.New().String() +} + +// ResolveOptions validates and fills in defaults for options +func ResolveOptions(opts Options) (Options, *ParsedDSN, error) { + parsedDSN, err := ParseDSN(opts.DSN) + if err != nil { + return opts, nil, err + } + + defaults := DefaultOptions() + + if opts.Environment == "" { + opts.Environment = defaults.Environment + } + if opts.AppVersion == "" { + opts.AppVersion = defaults.AppVersion + } + if opts.SampleRate == 0 { + opts.SampleRate = defaults.SampleRate + } + if opts.SampleRate < 0 { + opts.SampleRate = 0 + } + if opts.SampleRate > 1 { + opts.SampleRate = 1 + } + if opts.MaxBreadcrumbs == 0 { + opts.MaxBreadcrumbs = defaults.MaxBreadcrumbs + } + if opts.MaxOfflineQueueSize == 0 { + opts.MaxOfflineQueueSize = defaults.MaxOfflineQueueSize + } + if opts.APIBaseURL == "" { + opts.APIBaseURL = parsedDSN.APIBaseURL + } + + return opts, parsedDSN, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0100697 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/IronServices/irontelemetry-go + +go 1.21 + +require ( + github.com/google/uuid v1.5.0 +) diff --git a/journey.go b/journey.go new file mode 100644 index 0000000..91f1bf7 --- /dev/null +++ b/journey.go @@ -0,0 +1,227 @@ +package irontelemetry + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +// JourneyManager manages journey context +type JourneyManager struct { + mu sync.RWMutex + current *JourneyContext + client *Client +} + +// NewJourneyManager creates a new JourneyManager +func NewJourneyManager(client *Client) *JourneyManager { + return &JourneyManager{ + client: client, + } +} + +// StartJourney starts a new journey +func (jm *JourneyManager) StartJourney(name string) *Journey { + jm.mu.Lock() + defer jm.mu.Unlock() + + ctx := &JourneyContext{ + JourneyID: uuid.New().String(), + Name: name, + StartedAt: time.Now(), + Metadata: make(map[string]any), + } + jm.current = ctx + + // Add breadcrumb for journey start + if jm.client != nil { + jm.client.AddBreadcrumb("Started journey: "+name, CategoryBusiness) + } + + return &Journey{ + manager: jm, + context: ctx, + } +} + +// GetCurrent returns the current journey context +func (jm *JourneyManager) GetCurrent() *JourneyContext { + jm.mu.RLock() + defer jm.mu.RUnlock() + + return jm.current +} + +// Clear clears the current journey +func (jm *JourneyManager) Clear() { + jm.mu.Lock() + defer jm.mu.Unlock() + + jm.current = nil +} + +// Journey represents an active journey +type Journey struct { + manager *JourneyManager + context *JourneyContext +} + +// SetUser sets the user for the journey +func (j *Journey) SetUser(id, email, name string) *Journey { + j.manager.mu.Lock() + defer j.manager.mu.Unlock() + + j.context.Metadata["userId"] = id + if email != "" { + j.context.Metadata["userEmail"] = email + } + if name != "" { + j.context.Metadata["userName"] = name + } + + return j +} + +// SetMetadata sets metadata for the journey +func (j *Journey) SetMetadata(key string, value any) *Journey { + j.manager.mu.Lock() + defer j.manager.mu.Unlock() + + j.context.Metadata[key] = value + return j +} + +// StartStep starts a new step in the journey +func (j *Journey) StartStep(name string, category BreadcrumbCategory) *Step { + j.manager.mu.Lock() + j.context.CurrentStep = name + j.manager.mu.Unlock() + + // Add breadcrumb for step start + if j.manager.client != nil { + j.manager.client.AddBreadcrumbWithData( + "Started step: "+name, + category, + SeverityInfo, + map[string]any{ + "journeyId": j.context.JourneyID, + "journeyName": j.context.Name, + }, + ) + } + + return &Step{ + journey: j, + name: name, + category: category, + startedAt: time.Now(), + data: make(map[string]any), + } +} + +// Complete completes the journey successfully +func (j *Journey) Complete() { + if j.manager.client != nil { + j.manager.client.AddBreadcrumbWithData( + "Completed journey: "+j.context.Name, + CategoryBusiness, + SeverityInfo, + map[string]any{ + "journeyId": j.context.JourneyID, + "duration": time.Since(j.context.StartedAt).Milliseconds(), + }, + ) + } + j.manager.Clear() +} + +// Fail marks the journey as failed +func (j *Journey) Fail(err error) { + if j.manager.client != nil { + data := map[string]any{ + "journeyId": j.context.JourneyID, + "duration": time.Since(j.context.StartedAt).Milliseconds(), + } + if err != nil { + data["error"] = err.Error() + } + j.manager.client.AddBreadcrumbWithData( + "Failed journey: "+j.context.Name, + CategoryBusiness, + SeverityError, + data, + ) + } + j.manager.Clear() +} + +// Step represents a step within a journey +type Step struct { + journey *Journey + name string + category BreadcrumbCategory + startedAt time.Time + data map[string]any +} + +// SetData sets data for the step +func (s *Step) SetData(key string, value any) *Step { + s.data[key] = value + return s +} + +// Complete completes the step successfully +func (s *Step) Complete() { + if s.journey.manager.client != nil { + data := map[string]any{ + "journeyId": s.journey.context.JourneyID, + "journeyName": s.journey.context.Name, + "duration": time.Since(s.startedAt).Milliseconds(), + } + for k, v := range s.data { + data[k] = v + } + s.journey.manager.client.AddBreadcrumbWithData( + "Completed step: "+s.name, + s.category, + SeverityInfo, + data, + ) + } +} + +// Fail marks the step as failed +func (s *Step) Fail(err error) { + if s.journey.manager.client != nil { + data := map[string]any{ + "journeyId": s.journey.context.JourneyID, + "journeyName": s.journey.context.Name, + "duration": time.Since(s.startedAt).Milliseconds(), + } + for k, v := range s.data { + data[k] = v + } + if err != nil { + data["error"] = err.Error() + } + s.journey.manager.client.AddBreadcrumbWithData( + "Failed step: "+s.name, + s.category, + SeverityError, + data, + ) + } +} + +// RunStep is a helper that runs a function as a step +func (j *Journey) RunStep(name string, category BreadcrumbCategory, fn func() error) error { + step := j.StartStep(name, category) + err := fn() + if err != nil { + step.Fail(err) + return err + } + step.Complete() + return nil +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..50b4858 --- /dev/null +++ b/transport.go @@ -0,0 +1,171 @@ +package irontelemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Transport handles HTTP communication with the server +type Transport struct { + apiBaseURL string + publicKey string + debug bool + client *http.Client +} + +// NewTransport creates a new Transport +func NewTransport(parsedDSN *ParsedDSN, apiBaseURL string, debug bool) *Transport { + return &Transport{ + apiBaseURL: apiBaseURL, + publicKey: parsedDSN.PublicKey, + debug: debug, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Send sends an event to the server +func (t *Transport) Send(ctx context.Context, event *TelemetryEvent) SendResult { + url := t.apiBaseURL + "/api/v1/events" + + body, err := json.Marshal(t.serializeEvent(event)) + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return SendResult{Success: false, Error: err.Error()} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Public-Key", t.publicKey) + + resp, err := t.client.Do(req) + if err != nil { + if t.debug { + fmt.Printf("[IronTelemetry] Failed to send event: %v\n", err) + } + return SendResult{Success: false, Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + errMsg := fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + if t.debug { + fmt.Printf("[IronTelemetry] Failed to send event: %s\n", errMsg) + } + return SendResult{Success: false, Error: errMsg} + } + + var result struct { + EventID string `json:"eventId"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + // Even if we can't decode, the request succeeded + result.EventID = event.EventID + } + + if t.debug { + fmt.Printf("[IronTelemetry] Event sent successfully: %s\n", event.EventID) + } + + return SendResult{ + Success: true, + EventID: result.EventID, + } +} + +// IsOnline checks if the server is reachable +func (t *Transport) IsOnline(ctx context.Context) bool { + url := t.apiBaseURL + "/api/v1/health" + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return false + } + + req.Header.Set("X-Public-Key", t.publicKey) + + resp, err := t.client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == 200 +} + +func (t *Transport) serializeEvent(event *TelemetryEvent) map[string]any { + breadcrumbs := make([]map[string]any, len(event.Breadcrumbs)) + for i, b := range event.Breadcrumbs { + breadcrumbs[i] = map[string]any{ + "timestamp": b.Timestamp.Format(time.RFC3339), + "category": b.Category, + "message": b.Message, + "level": b.Level, + "data": b.Data, + } + } + + result := map[string]any{ + "eventId": event.EventID, + "timestamp": event.Timestamp.Format(time.RFC3339), + "level": event.Level, + "message": event.Message, + "tags": event.Tags, + "extra": event.Extra, + "breadcrumbs": breadcrumbs, + "environment": event.Environment, + "appVersion": event.AppVersion, + "platform": map[string]any{ + "name": event.Platform.Name, + "version": event.Platform.Version, + "os": event.Platform.OS, + }, + } + + if event.Exception != nil { + stacktrace := make([]map[string]any, len(event.Exception.Stacktrace)) + for i, f := range event.Exception.Stacktrace { + stacktrace[i] = map[string]any{ + "function": f.Function, + "filename": f.Filename, + "lineno": f.Lineno, + } + } + result["exception"] = map[string]any{ + "type": event.Exception.Type, + "message": event.Exception.Message, + "stacktrace": stacktrace, + } + } + + if event.User != nil { + result["user"] = map[string]any{ + "id": event.User.ID, + "email": event.User.Email, + "name": event.User.Name, + "data": event.User.Data, + } + } + + if event.Journey != nil { + result["journey"] = map[string]any{ + "journeyId": event.Journey.JourneyID, + "name": event.Journey.Name, + "currentStep": event.Journey.CurrentStep, + "startedAt": event.Journey.StartedAt.Format(time.RFC3339), + "metadata": event.Journey.Metadata, + } + } + + return result +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a193ea6 --- /dev/null +++ b/types.go @@ -0,0 +1,158 @@ +package irontelemetry + +import "time" + +// SeverityLevel represents the severity of an event +type SeverityLevel string + +const ( + SeverityDebug SeverityLevel = "debug" + SeverityInfo SeverityLevel = "info" + SeverityWarning SeverityLevel = "warning" + SeverityError SeverityLevel = "error" + SeverityFatal SeverityLevel = "fatal" +) + +// BreadcrumbCategory represents the category of a breadcrumb +type BreadcrumbCategory string + +const ( + CategoryUI BreadcrumbCategory = "ui" + CategoryHTTP BreadcrumbCategory = "http" + CategoryNavigation BreadcrumbCategory = "navigation" + CategoryConsole BreadcrumbCategory = "console" + CategoryAuth BreadcrumbCategory = "auth" + CategoryBusiness BreadcrumbCategory = "business" + CategoryNotification BreadcrumbCategory = "notification" + CategoryCustom BreadcrumbCategory = "custom" +) + +// Breadcrumb represents an event leading up to an error +type Breadcrumb struct { + Timestamp time.Time `json:"timestamp"` + Category BreadcrumbCategory `json:"category"` + Message string `json:"message"` + Level SeverityLevel `json:"level,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +// User represents user information for context +type User struct { + ID string `json:"id"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +// StackFrame represents a single frame in a stack trace +type StackFrame struct { + Function string `json:"function,omitempty"` + Filename string `json:"filename,omitempty"` + Lineno int `json:"lineno,omitempty"` + Colno int `json:"colno,omitempty"` + Context []string `json:"context,omitempty"` +} + +// ExceptionInfo represents exception/error information +type ExceptionInfo struct { + Type string `json:"type"` + Message string `json:"message"` + Stacktrace []StackFrame `json:"stacktrace,omitempty"` +} + +// PlatformInfo represents platform/runtime information +type PlatformInfo struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + OS string `json:"os,omitempty"` +} + +// JourneyContext represents journey context for tracking user flows +type JourneyContext struct { + JourneyID string `json:"journeyId"` + Name string `json:"name"` + CurrentStep string `json:"currentStep,omitempty"` + StartedAt time.Time `json:"startedAt"` + Metadata map[string]any `json:"metadata"` +} + +// TelemetryEvent represents an event payload sent to the server +type TelemetryEvent struct { + EventID string `json:"eventId"` + Timestamp time.Time `json:"timestamp"` + Level SeverityLevel `json:"level"` + Message string `json:"message,omitempty"` + Exception *ExceptionInfo `json:"exception,omitempty"` + User *User `json:"user,omitempty"` + Tags map[string]string `json:"tags"` + Extra map[string]any `json:"extra"` + Breadcrumbs []Breadcrumb `json:"breadcrumbs"` + Journey *JourneyContext `json:"journey,omitempty"` + Environment string `json:"environment,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + Platform PlatformInfo `json:"platform"` +} + +// ParsedDSN represents parsed DSN components +type ParsedDSN struct { + PublicKey string + Host string + Protocol string + APIBaseURL string +} + +// SendResult represents the result of sending an event +type SendResult struct { + Success bool + EventID string + Error string + Queued bool +} + +// Options represents options for initializing the SDK +type Options struct { + // DSN containing the public key + // Format: https://pk_live_xxx@irontelemetry.com + DSN string + + // Environment name (e.g., 'production', 'staging') + Environment string + + // Application version + AppVersion string + + // Sample rate for events (0.0 to 1.0) + SampleRate float64 + + // Maximum number of breadcrumbs to keep + MaxBreadcrumbs int + + // Enable debug logging + Debug bool + + // Hook called before sending an event + // Return nil to drop the event + BeforeSend func(*TelemetryEvent) *TelemetryEvent + + // Enable offline queue for failed events + EnableOfflineQueue bool + + // Maximum size of the offline queue + MaxOfflineQueueSize int + + // API base URL (defaults to parsed from DSN) + APIBaseURL string +} + +// DefaultOptions returns Options with default values +func DefaultOptions() Options { + return Options{ + Environment: "production", + AppVersion: "0.0.0", + SampleRate: 1.0, + MaxBreadcrumbs: 100, + Debug: false, + EnableOfflineQueue: true, + MaxOfflineQueueSize: 500, + } +}