From 09626831a2638450be7c823482ca2097c9454975 Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 10:17:52 +0000 Subject: [PATCH] Implement IronTelemetry Go SDK - Core client with exception/message capture - Journey and step tracking with breadcrumb correlation - Breadcrumb management with ring buffer - HTTP transport with context support - Full stack trace capture for exceptions - Thread-safe operations with mutex protection - Sample rate and beforeSend filtering - Tags, extras, and user context --- .gitignore | 29 +++++ README.md | 310 ++++++++++++++++++++++++++++++++++++++++++++++++- breadcrumbs.go | 96 +++++++++++++++ client.go | 284 ++++++++++++++++++++++++++++++++++++++++++++ config.go | 72 ++++++++++++ go.mod | 7 ++ journey.go | 227 ++++++++++++++++++++++++++++++++++++ transport.go | 171 +++++++++++++++++++++++++++ types.go | 158 +++++++++++++++++++++++++ 9 files changed, 1352 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 breadcrumbs.go create mode 100644 client.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 journey.go create mode 100644 transport.go create mode 100644 types.go 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, + } +}