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; } }