commit b5f4907ef7cef3902881049b9c3b62c468418310 Author: David Friedel Date: Thu Dec 25 09:10:35 2025 +0000 Initial commit: IronTelemetry.Client SDK Error monitoring and crash reporting SDK for .NET with: - Automatic exception capture - User journey tracking - Breadcrumb trails - Buffered sending with offline queue - Sample rate control diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776e6f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +*.DotSettings.user diff --git a/IronTelemetry.Client.csproj b/IronTelemetry.Client.csproj new file mode 100755 index 0000000..15fe789 --- /dev/null +++ b/IronTelemetry.Client.csproj @@ -0,0 +1,30 @@ + + + + net8.0;net9.0 + enable + enable + true + + + IronTelemetry.Client + 1.0.0 + David H Friedel Jr + MarketAlly + Client SDK for IronTelemetry - Error Monitoring and Crash Reporting. Capture exceptions, track user journeys, and monitor application health with automatic correlation. + telemetry;error-monitoring;crash-reporting;exceptions;diagnostics;apm;observability + MIT + https://github.com/ironservices/irontelemetry-client + https://www.irontelemetry.com + README.md + + + + + + + + + + + diff --git a/IronTelemetry.cs b/IronTelemetry.cs new file mode 100644 index 0000000..db8ac6c --- /dev/null +++ b/IronTelemetry.cs @@ -0,0 +1,231 @@ +namespace IronTelemetry.Client; + +/// +/// Static API for IronTelemetry. +/// Level 0: Drop-in error capture (Init + CaptureException) +/// Level 1: Ambient journey correlation (StartJourney + StartStep) +/// +public static class IronTelemetry +{ + private static TelemetryClient? _client; + private static readonly object _lock = new(); + + /// + /// Initialize IronTelemetry with a DSN. + /// + public static void Init(string dsn) + { + Init(new TelemetryOptions { Dsn = dsn }); + } + + /// + /// Initialize IronTelemetry with options. + /// + public static void Init(TelemetryOptions options) + { + lock (_lock) + { + _client = new TelemetryClient(options); + } + } + + #region Level 0 - Exception Capture + + /// + /// Capture an exception and send it to IronTelemetry. + /// Automatically correlates with current journey if one exists. + /// + public static void CaptureException(Exception ex) + { + EnsureInitialized(); + _client!.CaptureException(ex); + } + + /// + /// Capture an exception with additional context. + /// + public static void CaptureException(Exception ex, Action configure) + { + EnsureInitialized(); + var context = new ExceptionContext(); + configure(context); + _client!.CaptureException(ex, context); + } + + /// + /// Capture a message. + /// + public static void CaptureMessage(string message, TelemetryLevel level = TelemetryLevel.Info) + { + EnsureInitialized(); + _client!.CaptureMessage(message, level); + } + + /// + /// Add a breadcrumb to the current context. + /// + public static void AddBreadcrumb(string message, string? category = null) + { + EnsureInitialized(); + _client!.AddBreadcrumb(new Breadcrumb + { + Message = message, + Category = category ?? "default", + Level = BreadcrumbLevel.Info, + Timestamp = DateTime.UtcNow + }); + } + + /// + /// Add a breadcrumb to the current context. + /// + public static void AddBreadcrumb(Breadcrumb breadcrumb) + { + EnsureInitialized(); + _client!.AddBreadcrumb(breadcrumb); + } + + /// + /// Set the current user context. + /// + public static void SetUser(string? id, string? email = null, string? username = null) + { + EnsureInitialized(); + _client!.SetUser(id, email, username); + } + + /// + /// Set a global tag that will be sent with all events. + /// + public static void SetTag(string key, string value) + { + EnsureInitialized(); + _client!.SetTag(key, value); + } + + /// + /// Set extra data that will be sent with all events. + /// + public static void SetExtra(string key, object value) + { + EnsureInitialized(); + _client!.SetExtra(key, value); + } + + #endregion + + #region Level 1 - Journey Correlation + + /// + /// Start a new journey. All telemetry within this scope will be correlated. + /// + /// The journey name (e.g., "Checkout Flow", "User Onboarding") + /// A disposable scope - dispose to end the journey + /// + /// using (IronTelemetry.StartJourney("Checkout Flow")) + /// { + /// IronTelemetry.SetUser(currentUser.Id); + /// + /// using (IronTelemetry.StartStep("Validate Cart", "business")) + /// { + /// ValidateCart(); + /// } + /// + /// using (IronTelemetry.StartStep("Process Payment", "business")) + /// { + /// ProcessPayment(); + /// } + /// } + /// + public static JourneyScope StartJourney(string name) + { + EnsureInitialized(); + return JourneyContext.StartJourney(name); + } + + /// + /// Start a step within the current journey. + /// If no journey exists, one is created automatically. + /// + /// The step name (e.g., "Validate Cart", "Process Payment") + /// Optional category (e.g., "business", "technical", "navigation") + /// A disposable scope - dispose to end the step + public static StepScope StartStep(string name, string? category = null) + { + EnsureInitialized(); + return JourneyContext.StartStep(name, category); + } + + /// + /// Gets the current journey, if any. + /// + public static JourneyScope? CurrentJourney => JourneyContext.Current; + + /// + /// Gets the current step, if any. + /// + public static StepScope? CurrentStep => JourneyContext.CurrentStep; + + /// + /// Gets the current journey ID, if any. + /// + public static string? CurrentJourneyId => JourneyContext.CurrentJourneyId; + + /// + /// Set metadata on the current journey. + /// + public static void SetJourneyMetadata(string key, object value) + { + JourneyContext.SetMetadata(key, value); + } + + /// + /// Mark the current step as failed. + /// + public static void FailCurrentStep(string? reason = null) + { + JourneyContext.FailCurrentStep(reason); + } + + #endregion + + #region Flush + + /// + /// Flush any pending events synchronously. + /// + public static void Flush(TimeSpan? timeout = null) + { + _client?.Flush(timeout ?? TimeSpan.FromSeconds(5)); + } + + /// + /// Flush any pending events asynchronously. + /// + public static Task FlushAsync(TimeSpan? timeout = null) + { + return _client?.FlushAsync(timeout ?? TimeSpan.FromSeconds(5)) ?? Task.CompletedTask; + } + + #endregion + + /// + /// Get whether the SDK is initialized. + /// + public static bool IsInitialized => _client != null; + + /// + /// Get the underlying TelemetryClient instance. + /// Returns null if not initialized. + /// + public static TelemetryClient? Client => _client; + + private static void EnsureInitialized() + { + if (_client == null) + { + throw new InvalidOperationException( + "IronTelemetry has not been initialized. Call IronTelemetry.Init() first."); + } + } +} diff --git a/JourneyContext.cs b/JourneyContext.cs new file mode 100644 index 0000000..86b0467 --- /dev/null +++ b/JourneyContext.cs @@ -0,0 +1,283 @@ +namespace IronTelemetry.Client; + +/// +/// Ambient journey context that automatically flows through async calls. +/// Level 1 integration - no manual ID passing required. +/// +public static class JourneyContext +{ + private static readonly AsyncLocal _currentJourney = new(); + private static readonly AsyncLocal _currentStep = new(); + + /// + /// Gets the current journey, if any. + /// + public static JourneyScope? Current => _currentJourney.Value; + + /// + /// Gets the current step, if any. + /// + public static StepScope? CurrentStep => _currentStep.Value; + + /// + /// Gets the current journey ID, if any. + /// + public static string? CurrentJourneyId => _currentJourney.Value?.JourneyId; + + /// + /// Gets the current step ID, if any. + /// + public static string? CurrentStepId => _currentStep.Value?.StepId; + + /// + /// Start a new journey. All telemetry within this scope will be correlated. + /// + /// The journey name (e.g., "Checkout Flow", "User Onboarding") + /// A disposable scope - dispose to end the journey + public static JourneyScope StartJourney(string name) + { + var journey = new JourneyScope(name); + _currentJourney.Value = journey; + return journey; + } + + /// + /// Start a step within the current journey. + /// If no journey exists, one is created automatically. + /// + /// The step name (e.g., "Validate Cart", "Process Payment") + /// Optional category (e.g., "business", "technical", "navigation") + /// A disposable scope - dispose to end the step + public static StepScope StartStep(string name, string? category = null) + { + // Auto-create journey if none exists + if (_currentJourney.Value == null) + { + StartJourney("Auto Journey"); + } + + var step = new StepScope(_currentJourney.Value!, name, category); + _currentStep.Value = step; + return step; + } + + /// + /// Set the user for the current journey. + /// + public static void SetUser(string userId, string? email = null, string? username = null) + { + if (_currentJourney.Value != null) + { + _currentJourney.Value.SetUser(userId, email, username); + } + + // Also set on the global client + IronTelemetry.SetUser(userId, email, username); + } + + /// + /// Add metadata to the current journey. + /// + public static void SetMetadata(string key, object value) + { + _currentJourney.Value?.SetMetadata(key, value); + } + + /// + /// Mark the current step as failed. + /// + public static void FailCurrentStep(string? reason = null) + { + _currentStep.Value?.Fail(reason); + } + + internal static void ClearJourney() + { + _currentJourney.Value = null; + _currentStep.Value = null; + } + + internal static void ClearStep() + { + _currentStep.Value = null; + } +} + +/// +/// Represents an active journey scope. +/// +public class JourneyScope : IDisposable +{ + private readonly DateTime _startTime; + private bool _disposed; + + public string JourneyId { get; } + public string Name { get; } + public string? UserId { get; private set; } + public string? UserEmail { get; private set; } + public string? Username { get; private set; } + public JourneyStatus Status { get; private set; } = JourneyStatus.InProgress; + public Dictionary Metadata { get; } = new(); + + internal JourneyScope(string name) + { + JourneyId = Guid.NewGuid().ToString(); + Name = name; + _startTime = DateTime.UtcNow; + + // Send journey start event + TelemetryClient.CurrentClient?.EnqueueJourneyStart(this); + } + + public void SetUser(string userId, string? email = null, string? username = null) + { + UserId = userId; + UserEmail = email; + Username = username; + } + + public void SetMetadata(string key, object value) + { + Metadata[key] = value; + } + + public void Complete() + { + Status = JourneyStatus.Completed; + } + + public void Fail(string? reason = null) + { + Status = JourneyStatus.Failed; + if (reason != null) + { + Metadata["failureReason"] = reason; + } + } + + public void Abandon() + { + Status = JourneyStatus.Abandoned; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + var duration = DateTime.UtcNow - _startTime; + Metadata["durationMs"] = duration.TotalMilliseconds; + + // If still in progress, mark as completed + if (Status == JourneyStatus.InProgress) + { + Status = JourneyStatus.Completed; + } + + // Send journey end event + TelemetryClient.CurrentClient?.EnqueueJourneyEnd(this); + + JourneyContext.ClearJourney(); + } +} + +/// +/// Represents an active step within a journey. +/// +public class StepScope : IDisposable +{ + private readonly JourneyScope _journey; + private readonly DateTime _startTime; + private readonly StepScope? _parentStep; + private bool _disposed; + + public string StepId { get; } + public string Name { get; } + public string? Category { get; } + public StepStatus Status { get; private set; } = StepStatus.InProgress; + public string? FailureReason { get; private set; } + public Dictionary Data { get; } = new(); + + internal StepScope(JourneyScope journey, string name, string? category) + { + _journey = journey; + _parentStep = JourneyContext.CurrentStep; + StepId = Guid.NewGuid().ToString(); + Name = name; + Category = category; + _startTime = DateTime.UtcNow; + + // Send step start event + TelemetryClient.CurrentClient?.EnqueueStepStart(this, journey.JourneyId); + } + + public string? ParentStepId => _parentStep?.StepId; + + public void SetData(string key, object value) + { + Data[key] = value; + } + + public void Complete() + { + Status = StepStatus.Completed; + } + + public void Fail(string? reason = null) + { + Status = StepStatus.Failed; + FailureReason = reason; + } + + public void Skip(string? reason = null) + { + Status = StepStatus.Skipped; + if (reason != null) + { + Data["skipReason"] = reason; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + var duration = DateTime.UtcNow - _startTime; + Data["durationMs"] = duration.TotalMilliseconds; + + // If still in progress, mark as completed + if (Status == StepStatus.InProgress) + { + Status = StepStatus.Completed; + } + + // Send step end event + TelemetryClient.CurrentClient?.EnqueueStepEnd(this, _journey.JourneyId); + + // Restore parent step as current + JourneyContext.ClearStep(); + } +} + +/// +/// Journey status for SDK tracking. +/// +public enum JourneyStatus +{ + InProgress, + Completed, + Failed, + Abandoned +} + +/// +/// Step status for SDK tracking. +/// +public enum StepStatus +{ + InProgress, + Completed, + Failed, + Skipped +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79f68de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 IronServices + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Models.cs b/Models.cs new file mode 100644 index 0000000..93d78ad --- /dev/null +++ b/Models.cs @@ -0,0 +1,68 @@ +namespace IronTelemetry.Client; + +/// +/// Telemetry level for messages. +/// +public enum TelemetryLevel +{ + Debug, + Info, + Warning, + Error, + Fatal +} + +/// +/// Breadcrumb level. +/// +public enum BreadcrumbLevel +{ + Debug, + Info, + Warning, + Error +} + +/// +/// A breadcrumb represents an event that happened before an error. +/// +public class Breadcrumb +{ + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public string Category { get; set; } = "default"; + public string Message { get; set; } = string.Empty; + public BreadcrumbLevel Level { get; set; } = BreadcrumbLevel.Info; + public Dictionary? Data { get; set; } +} + +/// +/// Additional context for exception capture. +/// +public class ExceptionContext +{ + public string? UserId { get; set; } + public string? UserEmail { get; set; } + public string? TraceId { get; set; } + public string? SpanId { get; set; } + public Dictionary Extras { get; set; } = new(); + + public ExceptionContext WithUser(string? id, string? email = null) + { + UserId = id; + UserEmail = email; + return this; + } + + public ExceptionContext WithTrace(string traceId, string? spanId = null) + { + TraceId = traceId; + SpanId = spanId; + return this; + } + + public ExceptionContext WithExtra(string key, object value) + { + Extras[key] = value; + return this; + } +} diff --git a/OfflineQueue.cs b/OfflineQueue.cs new file mode 100644 index 0000000..dfa0106 --- /dev/null +++ b/OfflineQueue.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IronTelemetry.Client; + +/// +/// File-based offline queue for telemetry items. +/// Persists failed sends to disk and retries with exponential backoff. +/// +public class OfflineQueue : IDisposable +{ + private readonly string _queueFilePath; + private readonly int _maxQueueSize; + private readonly object _fileLock = new(); + private readonly SemaphoreSlim _retrySemaphore = new(1, 1); + private readonly Timer? _retryTimer; + private readonly Func, Task> _sendFunc; + private int _retryAttempt; + private bool _disposed; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + /// + /// Creates a new offline queue. + /// + /// Function to send items to server. Returns true on success. + /// Directory to store queue file. Defaults to app data. + /// Maximum items to store. Oldest items dropped when exceeded. + /// Whether to automatically retry sending queued items. + public OfflineQueue( + Func, Task> sendFunc, + string? queueDirectory = null, + int maxQueueSize = 1000, + bool enableAutoRetry = true) + { + _sendFunc = sendFunc; + _maxQueueSize = maxQueueSize; + + var directory = queueDirectory ?? GetDefaultQueueDirectory(); + Directory.CreateDirectory(directory); + _queueFilePath = Path.Combine(directory, "telemetry_queue.json"); + + if (enableAutoRetry) + { + // Start retry timer - initial delay 30 seconds, then every 60 seconds + _retryTimer = new Timer( + _ => _ = RetryQueuedItemsAsync(), + null, + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60)); + } + } + + /// + /// Number of items currently in the queue. + /// + public int Count + { + get + { + lock (_fileLock) + { + var items = LoadQueue(); + return items.Count; + } + } + } + + /// + /// Enqueue items that failed to send. + /// + public void Enqueue(List items) + { + if (items.Count == 0) return; + + lock (_fileLock) + { + var queue = LoadQueue(); + queue.AddRange(items); + + // Trim to max size (remove oldest) + while (queue.Count > _maxQueueSize) + { + queue.RemoveAt(0); + } + + SaveQueue(queue); + } + } + + /// + /// Try to send all queued items. + /// + public async Task RetryQueuedItemsAsync() + { + if (!await _retrySemaphore.WaitAsync(0)) + { + // Already retrying + return false; + } + + try + { + List items; + lock (_fileLock) + { + items = LoadQueue(); + } + + if (items.Count == 0) + { + _retryAttempt = 0; + return true; + } + + // Try to send + var success = await _sendFunc(items); + + if (success) + { + // Clear the queue + lock (_fileLock) + { + SaveQueue(new List()); + } + _retryAttempt = 0; + return true; + } + else + { + // Exponential backoff - adjust retry timer + _retryAttempt++; + return false; + } + } + catch + { + _retryAttempt++; + return false; + } + finally + { + _retrySemaphore.Release(); + } + } + + /// + /// Clear all queued items without sending. + /// + public void Clear() + { + lock (_fileLock) + { + SaveQueue(new List()); + } + } + + /// + /// Get all queued items (for display/export). + /// + public List GetQueuedItems() + { + lock (_fileLock) + { + return LoadQueue(); + } + } + + private List LoadQueue() + { + try + { + if (!File.Exists(_queueFilePath)) + { + return new List(); + } + + var json = File.ReadAllText(_queueFilePath); + return JsonSerializer.Deserialize>(json, JsonOptions) + ?? new List(); + } + catch + { + return new List(); + } + } + + private void SaveQueue(List items) + { + try + { + var json = JsonSerializer.Serialize(items, JsonOptions); + File.WriteAllText(_queueFilePath, json); + } + catch + { + // Ignore write errors + } + } + + private static string GetDefaultQueueDirectory() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "IronTelemetry", + "Queue"); + } + + public void Dispose() + { + if (!_disposed) + { + _retryTimer?.Dispose(); + _retrySemaphore.Dispose(); + _disposed = true; + } + } +} + +/// +/// Options for offline queue behavior. +/// +public class OfflineQueueOptions +{ + /// + /// Directory to store queue files. Defaults to LocalApplicationData/IronTelemetry/Queue. + /// + public string? QueueDirectory { get; set; } + + /// + /// Maximum number of items to store in the queue. Oldest items dropped when exceeded. + /// Default: 1000 + /// + public int MaxQueueSize { get; set; } = 1000; + + /// + /// Whether to automatically retry sending queued items in the background. + /// Default: true + /// + public bool EnableAutoRetry { get; set; } = true; + + /// + /// Initial retry delay after a failed send. Default: 30 seconds. + /// + public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Maximum retry delay (for exponential backoff). Default: 5 minutes. + /// + public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..80802aa --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# IronTelemetry.Client + +Error monitoring and crash reporting SDK for .NET applications. + +## Installation + +```bash +dotnet add package IronTelemetry.Client +``` + +## Quick Start + +### Level 0: Basic Exception Capture + +```csharp +using IronTelemetry.Client; + +// Initialize with your DSN +IronTelemetry.Init("https://pk_live_xxx@irontelemetry.com"); + +// Capture exceptions +try +{ + DoSomething(); +} +catch (Exception ex) +{ + IronTelemetry.CaptureException(ex); + throw; +} + +// Or use the extension method +catch (Exception ex) +{ + throw ex.Capture(); +} +``` + +### Level 1: Journey Tracking + +Track user journeys to understand the context of errors: + +```csharp +using IronTelemetry.Client; + +// Track a complete user journey +using (IronTelemetry.StartJourney("Checkout Flow")) +{ + IronTelemetry.SetUser(currentUser.Id, currentUser.Email); + + using (IronTelemetry.StartStep("Validate Cart", "business")) + { + ValidateCart(); + } + + using (IronTelemetry.StartStep("Process Payment", "business")) + { + ProcessPayment(); + } + + using (IronTelemetry.StartStep("Send Confirmation", "notification")) + { + SendConfirmationEmail(); + } +} +``` + +Any exceptions captured during the journey are automatically correlated. + +## Configuration + +```csharp +IronTelemetry.Init(new TelemetryOptions +{ + Dsn = "https://pk_live_xxx@irontelemetry.com", + Environment = "production", + AppVersion = "1.2.3", + SampleRate = 1.0, // 100% of events + Debug = false, + BeforeSend = ex => !ex.Message.Contains("expected error") +}); +``` + +## Features + +- **Automatic Exception Capture**: Capture and report exceptions with full stack traces +- **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 +- **Buffered Sending**: Events are batched and sent efficiently +- **Sample Rate**: Control the volume of events sent +- **Before Send Hook**: Filter events before sending + +## Breadcrumbs + +```csharp +// Add breadcrumbs to understand what happened before an error +IronTelemetry.AddBreadcrumb("User clicked checkout button", "ui"); +IronTelemetry.AddBreadcrumb("Payment API called", "http"); + +// Or with full control +IronTelemetry.AddBreadcrumb(new Breadcrumb +{ + Category = "auth", + Message = "User logged in", + Level = BreadcrumbLevel.Info, + Data = new Dictionary { ["userId"] = "123" } +}); +``` + +## Global Exception Handling + +For console applications: + +```csharp +IronTelemetry.Init("your-dsn"); +TelemetryExtensions.UseUnhandledExceptionHandler(); +``` + +## Helper Methods + +```csharp +// Track a step with automatic error handling +TelemetryExtensions.TrackStep("Process Order", () => +{ + ProcessOrder(); +}); + +// Async version +await TelemetryExtensions.TrackStepAsync("Fetch Data", async () => +{ + await FetchDataAsync(); +}); + +// With return value +var result = TelemetryExtensions.TrackStep("Calculate Total", () => +{ + return CalculateTotal(); +}); +``` + +## Flushing + +```csharp +// Flush pending events before app shutdown +IronTelemetry.Flush(); + +// Or async +await IronTelemetry.FlushAsync(); +``` + +## Links + +- [Documentation](https://www.irontelemetry.com/docs) +- [Dashboard](https://www.irontelemetry.com) +- [Support](https://www.irontelemetry.com/support) diff --git a/TelemetryClient.cs b/TelemetryClient.cs new file mode 100644 index 0000000..b7e569f --- /dev/null +++ b/TelemetryClient.cs @@ -0,0 +1,537 @@ +using System.Collections.Concurrent; +using System.Net.Http.Json; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace IronTelemetry.Client; + +/// +/// Core client for sending telemetry to IronTelemetry API. +/// +public class TelemetryClient : IDisposable +{ + private readonly TelemetryOptions _options; + private readonly HttpClient _httpClient; + private readonly ConcurrentQueue _breadcrumbs = new(); + private readonly ConcurrentDictionary _tags = new(); + private readonly ConcurrentDictionary _extras = new(); + private readonly ConcurrentQueue _pendingItems = new(); + private readonly ConcurrentQueue _localLogQueue = new(); + private readonly SemaphoreSlim _sendSemaphore = new(1, 1); + private readonly Timer _flushTimer; + private readonly OfflineQueue? _offlineQueue; + + private string? _userId; + private string? _userEmail; + private string? _userName; + + private readonly string _baseUrl; + private readonly string _publicKey; + + /// + /// Gets the current TelemetryClient instance (used by JourneyContext). + /// + internal static TelemetryClient? CurrentClient { get; private set; } + + /// + /// Gets the offline queue for accessing queued items. + /// + public OfflineQueue? OfflineQueue => _offlineQueue; + + /// + /// Gets all locally captured log items for viewing in AppLogView. + /// + public IReadOnlyList GetLocalLogItems() => _localLogQueue.ToArray(); + + /// + /// Clears the local log queue. + /// + public void ClearLocalLogItems() + { + while (_localLogQueue.TryDequeue(out _)) { } + } + + /// + /// Maximum number of items to keep in the local log queue. + /// + private const int MaxLocalLogItems = 100; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + public TelemetryClient(TelemetryOptions options) + { + _options = options; + + // Parse DSN + var (baseUrl, publicKey) = ParseDsn(options.Dsn); + _baseUrl = baseUrl; + _publicKey = publicKey; + + // Create HTTP client + var handler = options.HttpHandler ?? new HttpClientHandler(); + _httpClient = new HttpClient(handler) + { + Timeout = options.SendTimeout + }; + _httpClient.DefaultRequestHeaders.Add("X-Telemetry-DSN", options.Dsn); + + // Periodic flush timer (every 5 seconds) + _flushTimer = new Timer(_ => _ = FlushAsync(), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + + // Initialize offline queue if enabled + if (options.EnableOfflineQueue) + { + _offlineQueue = new OfflineQueue( + SendItemsToServerAsync, + options.OfflineQueueDirectory, + options.MaxOfflineQueueSize, + enableAutoRetry: true); + } + + // Set as current client + CurrentClient = this; + } + + public void CaptureException(Exception ex, ExceptionContext? context = null) + { + if (!ShouldCapture(ex)) + { + return; + } + + // Auto-correlate with current journey context + var currentJourney = JourneyContext.Current; + var currentStep = JourneyContext.CurrentStep; + + var item = new EnvelopeItem + { + Type = "exception", + ExceptionType = ex.GetType().FullName ?? ex.GetType().Name, + Message = ex.Message, + StackTrace = ex.StackTrace, + AppVersion = _options.AppVersion, + AppBuild = _options.AppBuild, + Environment = _options.Environment, + OsName = GetOsName(), + OsVersion = System.Environment.OSVersion.VersionString, + RuntimeVersion = RuntimeInformation.FrameworkDescription, + UserId = context?.UserId ?? currentJourney?.UserId ?? _userId, + UserEmail = context?.UserEmail ?? currentJourney?.UserEmail ?? _userEmail, + JourneyId = currentJourney?.JourneyId, + StepId = currentStep?.StepId, + TraceId = context?.TraceId, + SpanId = context?.SpanId, + Breadcrumbs = GetBreadcrumbs(), + Metadata = MergeMetadata(context?.Extras, currentJourney?.Metadata) + }; + + // Add tags to metadata + foreach (var tag in _tags) + { + item.Metadata[$"tag.{tag.Key}"] = tag.Value; + } + + // Mark current step as failed if there is one + currentStep?.Fail(ex.Message); + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + + if (_options.Debug) + { + var journeyInfo = currentJourney != null ? $" (journey: {currentJourney.Name})" : ""; + Console.WriteLine($"[IronTelemetry] Captured exception: {ex.GetType().Name}: {ex.Message}{journeyInfo}"); + } + } + + public void CaptureMessage(string message, TelemetryLevel level) + { + var currentJourney = JourneyContext.Current; + var currentStep = JourneyContext.CurrentStep; + + var item = new EnvelopeItem + { + Type = "message", + ExceptionType = level.ToString(), + Message = message, + AppVersion = _options.AppVersion, + AppBuild = _options.AppBuild, + Environment = _options.Environment, + UserId = currentJourney?.UserId ?? _userId, + UserEmail = currentJourney?.UserEmail ?? _userEmail, + JourneyId = currentJourney?.JourneyId, + StepId = currentStep?.StepId, + Breadcrumbs = GetBreadcrumbs(), + Metadata = new Dictionary(_extras) + }; + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + } + + public void AddBreadcrumb(Breadcrumb breadcrumb) + { + _breadcrumbs.Enqueue(breadcrumb); + + // Trim to max + while (_breadcrumbs.Count > _options.MaxBreadcrumbs) + { + _breadcrumbs.TryDequeue(out _); + } + } + + public void SetUser(string? id, string? email, string? username) + { + _userId = id; + _userEmail = email; + _userName = username; + } + + public void SetTag(string key, string value) + { + _tags[key] = value; + } + + public void SetExtra(string key, object value) + { + _extras[key] = value; + } + + /// + /// Start a step using the legacy API (Level 0 compatibility). + /// For Level 1, use JourneyContext.StartStep() instead. + /// + public IDisposable StartStep(string name) + { + return JourneyContext.StartStep(name); + } + + #region Journey Context Integration + + internal void EnqueueJourneyStart(JourneyScope journey) + { + var item = new EnvelopeItem + { + Type = "journey_start", + JourneyId = journey.JourneyId, + Name = journey.Name, + UserId = journey.UserId ?? _userId, + UserEmail = journey.UserEmail ?? _userEmail, + AppVersion = _options.AppVersion, + AppBuild = _options.AppBuild, + Environment = _options.Environment, + OsName = GetOsName(), + OsVersion = System.Environment.OSVersion.VersionString, + RuntimeVersion = RuntimeInformation.FrameworkDescription + }; + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Started journey: {journey.Name} ({journey.JourneyId})"); + } + } + + internal void EnqueueJourneyEnd(JourneyScope journey) + { + var item = new EnvelopeItem + { + Type = "journey_end", + JourneyId = journey.JourneyId, + Name = journey.Name, + Status = journey.Status.ToString(), + UserId = journey.UserId ?? _userId, + UserEmail = journey.UserEmail ?? _userEmail, + Metadata = new Dictionary(journey.Metadata) + }; + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Ended journey: {journey.Name} ({journey.Status})"); + } + } + + internal void EnqueueStepStart(StepScope step, string journeyId) + { + var item = new EnvelopeItem + { + Type = "step_start", + JourneyId = journeyId, + StepId = step.StepId, + ParentStepId = step.ParentStepId, + Name = step.Name, + Category = step.Category + }; + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Started step: {step.Name}"); + } + } + + internal void EnqueueStepEnd(StepScope step, string journeyId) + { + var item = new EnvelopeItem + { + Type = "step_end", + JourneyId = journeyId, + StepId = step.StepId, + Name = step.Name, + Status = step.Status.ToString(), + Category = step.Category, + Data = new Dictionary(step.Data) + }; + + if (step.FailureReason != null) + { + item.Data["failureReason"] = step.FailureReason; + } + + _pendingItems.Enqueue(item); + AddToLocalLog(item); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Ended step: {step.Name} ({step.Status})"); + } + } + + #endregion + + public void Flush(TimeSpan timeout) + { + FlushAsync(timeout).GetAwaiter().GetResult(); + } + + public async Task FlushAsync(TimeSpan? timeout = null) + { + if (_pendingItems.IsEmpty) + { + return; + } + + if (!await _sendSemaphore.WaitAsync(timeout ?? TimeSpan.FromSeconds(5))) + { + return; + } + + try + { + var items = new List(); + while (_pendingItems.TryDequeue(out var item)) + { + items.Add(item); + } + + if (items.Count == 0) + { + return; + } + + var success = await SendItemsToServerAsync(items); + + if (!success && _offlineQueue != null) + { + // Queue for retry + _offlineQueue.Enqueue(items); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Queued {items.Count} items for offline retry"); + } + } + } + finally + { + _sendSemaphore.Release(); + } + } + + /// + /// Send items to the server. Returns true on success. + /// + private async Task SendItemsToServerAsync(List items) + { + try + { + var envelope = new { items }; + var url = $"{_baseUrl}/api/v1/envelope"; + + var response = await _httpClient.PostAsJsonAsync(url, envelope, JsonOptions); + + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Sent {items.Count} items, status: {response.StatusCode}"); + } + + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + if (_options.Debug) + { + Console.WriteLine($"[IronTelemetry] Failed to send: {ex.Message}"); + } + return false; + } + } + + public void Dispose() + { + _flushTimer.Dispose(); + Flush(TimeSpan.FromSeconds(2)); + _offlineQueue?.Dispose(); + _httpClient.Dispose(); + _sendSemaphore.Dispose(); + + if (CurrentClient == this) + { + CurrentClient = null; + } + } + + private bool ShouldCapture(Exception ex) + { + // Check sample rate + if (_options.SampleRate < 1.0 && Random.Shared.NextDouble() > _options.SampleRate) + { + return false; + } + + // Check before send callback + if (_options.BeforeSend != null && !_options.BeforeSend(ex)) + { + return false; + } + + return true; + } + + private List GetBreadcrumbs() + { + return _breadcrumbs.Select(b => new BreadcrumbPayload + { + Timestamp = b.Timestamp, + Category = b.Category, + Message = b.Message, + Level = b.Level.ToString(), + Data = b.Data + }).ToList(); + } + + private Dictionary MergeMetadata(Dictionary? extras, Dictionary? journeyMetadata = null) + { + var result = new Dictionary(_extras); + + if (journeyMetadata != null) + { + foreach (var kvp in journeyMetadata) + { + result[$"journey.{kvp.Key}"] = kvp.Value; + } + } + + if (extras != null) + { + foreach (var kvp in extras) + { + result[kvp.Key] = kvp.Value; + } + } + + return result; + } + + private static string GetOsName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS"; + return "Unknown"; + } + + /// + /// Add an item to the local log queue for viewing in AppLogView. + /// + private void AddToLocalLog(EnvelopeItem item) + { + _localLogQueue.Enqueue(item); + + // Trim to max size + while (_localLogQueue.Count > MaxLocalLogItems) + { + _localLogQueue.TryDequeue(out _); + } + } + + private static (string baseUrl, string publicKey) ParseDsn(string dsn) + { + // Format: https://{public_key}@{host} + var uri = new Uri(dsn); + var publicKey = uri.UserInfo; + var host = uri.Host; + var baseUrl = $"{uri.Scheme}://{host}"; + + return (baseUrl, publicKey); + } +} + +/// +/// Represents a telemetry item (exception, message, journey, step). +/// +public class EnvelopeItem +{ + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public string? Type { get; set; } + public string? JourneyId { get; set; } + public string? StepId { get; set; } + public string? ParentStepId { get; set; } + public string? ExceptionType { get; set; } + public string? Message { get; set; } + public string? StackTrace { get; set; } + public string? AppVersion { get; set; } + public string? AppBuild { get; set; } + public string? Environment { get; set; } + public string? OsName { get; set; } + public string? OsVersion { get; set; } + public string? DeviceModel { get; set; } + public string? RuntimeVersion { get; set; } + public string? UserId { get; set; } + public string? UserEmail { get; set; } + public string? TraceId { get; set; } + public string? SpanId { get; set; } + public string? Name { get; set; } + public string? Category { get; set; } + public string? SessionId { get; set; } + public string? DeviceId { get; set; } + public string? Status { get; set; } + public List Breadcrumbs { get; set; } = []; + public Dictionary Metadata { get; set; } = []; + public Dictionary Data { get; set; } = []; +} + +/// +/// Breadcrumb payload for telemetry items. +/// +public class BreadcrumbPayload +{ + public DateTime? Timestamp { get; set; } + public string Category { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Level { get; set; } = "Info"; + public Dictionary? Data { get; set; } +} diff --git a/TelemetryExtensions.cs b/TelemetryExtensions.cs new file mode 100644 index 0000000..bc7912d --- /dev/null +++ b/TelemetryExtensions.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; + +namespace IronTelemetry.Client; + +/// +/// Extension methods for IronTelemetry integration. +/// +public static class TelemetryExtensions +{ + /// + /// Configure global unhandled exception handling for console applications. + /// + public static void UseUnhandledExceptionHandler() + { + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + if (args.ExceptionObject is Exception ex) + { + IronTelemetry.CaptureException(ex, ctx => + ctx.WithExtra("isTerminating", args.IsTerminating) + .WithExtra("source", "UnhandledException")); + + // Flush synchronously since the app may be terminating + IronTelemetry.Flush(TimeSpan.FromSeconds(2)); + } + }; + + TaskScheduler.UnobservedTaskException += (sender, args) => + { + IronTelemetry.CaptureException(args.Exception, ctx => + ctx.WithExtra("source", "UnobservedTaskException")); + + // Mark as observed to prevent app crash + args.SetObserved(); + }; + } + + /// + /// Capture an exception and return it (for throw expressions). + /// + public static Exception Capture(this Exception ex) + { + IronTelemetry.CaptureException(ex); + return ex; + } + + /// + /// Capture an exception with context and return it. + /// + public static Exception Capture(this Exception ex, Action configure) + { + IronTelemetry.CaptureException(ex, configure); + return ex; + } + + /// + /// Create a step scope that automatically tracks duration. + /// + public static StepScope TimeStep(this TelemetryClient client, string name, string? category = null) + { + return JourneyContext.StartStep(name, category); + } + + /// + /// Execute an action within a tracked step. + /// + public static void TrackStep(string name, Action action, string? category = null) + { + using var step = IronTelemetry.StartStep(name, category); + try + { + action(); + } + catch (Exception ex) + { + step.Fail(ex.Message); + throw; + } + } + + /// + /// Execute an async action within a tracked step. + /// + public static async Task TrackStepAsync(string name, Func action, string? category = null) + { + using var step = IronTelemetry.StartStep(name, category); + try + { + await action(); + } + catch (Exception ex) + { + step.Fail(ex.Message); + throw; + } + } + + /// + /// Execute a function within a tracked step and return the result. + /// + public static T TrackStep(string name, Func func, string? category = null) + { + using var step = IronTelemetry.StartStep(name, category); + try + { + return func(); + } + catch (Exception ex) + { + step.Fail(ex.Message); + throw; + } + } + + /// + /// Execute an async function within a tracked step and return the result. + /// + public static async Task TrackStepAsync(string name, Func> func, string? category = null) + { + using var step = IronTelemetry.StartStep(name, category); + try + { + return await func(); + } + catch (Exception ex) + { + step.Fail(ex.Message); + throw; + } + } + + /// + /// Add Activity trace context to the current telemetry. + /// + public static ExceptionContext WithActivity(this ExceptionContext context, Activity? activity = null) + { + activity ??= Activity.Current; + if (activity != null) + { + context.TraceId = activity.TraceId.ToString(); + context.SpanId = activity.SpanId.ToString(); + } + return context; + } + + /// + /// Execute a journey and return the result. + /// + public static T RunJourney(string name, Func action) + { + using var journey = IronTelemetry.StartJourney(name); + try + { + var result = action(); + journey.Complete(); + return result; + } + catch (Exception ex) + { + journey.Fail(ex.Message); + IronTelemetry.CaptureException(ex); + throw; + } + } + + /// + /// Execute an async journey and return the result. + /// + public static async Task RunJourneyAsync(string name, Func> action) + { + using var journey = IronTelemetry.StartJourney(name); + try + { + var result = await action(); + journey.Complete(); + return result; + } + catch (Exception ex) + { + journey.Fail(ex.Message); + IronTelemetry.CaptureException(ex); + throw; + } + } +} diff --git a/TelemetryOptions.cs b/TelemetryOptions.cs new file mode 100644 index 0000000..4ad643d --- /dev/null +++ b/TelemetryOptions.cs @@ -0,0 +1,81 @@ +namespace IronTelemetry.Client; + +/// +/// Configuration options for IronTelemetry SDK. +/// +public class TelemetryOptions +{ + /// + /// The DSN (Data Source Name) for your project. + /// Format: https://{public_key}@irontelemetry.com + /// + public string Dsn { get; set; } = string.Empty; + + /// + /// The environment (e.g., "production", "staging", "development"). + /// + public string? Environment { get; set; } + + /// + /// The application version. + /// + public string? AppVersion { get; set; } + + /// + /// The application build number. + /// + public string? AppBuild { get; set; } + + /// + /// Sample rate for events (0.0 to 1.0). Default is 1.0 (100%). + /// + public double SampleRate { get; set; } = 1.0; + + /// + /// Maximum number of breadcrumbs to store. Default is 100. + /// + public int MaxBreadcrumbs { get; set; } = 100; + + /// + /// Enable debug logging. Default is false. + /// + public bool Debug { get; set; } + + /// + /// Callback to filter exceptions before sending. + /// Return false to skip sending the exception. + /// + public Func? BeforeSend { get; set; } + + /// + /// Whether to attach stack trace to messages. Default is false. + /// + public bool AttachStacktrace { get; set; } + + /// + /// Timeout for sending events. Default is 5 seconds. + /// + public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Custom HTTP handler for testing or proxy scenarios. + /// + public HttpMessageHandler? HttpHandler { get; set; } + + /// + /// Enable offline queue for failed sends. Default is true. + /// When enabled, failed sends are persisted to disk and retried automatically. + /// + public bool EnableOfflineQueue { get; set; } = true; + + /// + /// Directory to store offline queue. Defaults to LocalApplicationData/IronTelemetry/Queue. + /// + public string? OfflineQueueDirectory { get; set; } + + /// + /// Maximum items to store in offline queue. Oldest items dropped when exceeded. + /// Default: 1000 + /// + public int MaxOfflineQueueSize { get; set; } = 1000; +}