using System.Collections.Concurrent;
using System.Diagnostics;
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 = GetBreadcrumbPayloads(),
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);
var journeyInfo = currentJourney != null ? $" (journey: {currentJourney.Name})" : "";
LogDebug($"Captured exception: {ex.GetType().Name}: {ex.Message}{journeyInfo}");
}
public void CaptureMessage(string message, TelemetryLevel level)
{
LogMessage(level.ToString(), message, null, null);
}
///
/// Log a message with a title, message, and optional metadata.
/// This is useful for debugging and informational logging without exceptions.
///
/// Log level: "info", "warning", "error", "debug"
/// Short title for the log entry
/// Detailed message
/// Optional additional data
public void LogMessage(string level, string title, string? message = null, Dictionary? data = null)
{
var currentJourney = JourneyContext.Current;
var currentStep = JourneyContext.CurrentStep;
var metadata = new Dictionary(_extras);
if (data != null)
{
foreach (var kvp in data)
{
metadata[kvp.Key] = kvp.Value;
}
}
var item = new EnvelopeItem
{
Type = "message",
ExceptionType = level,
Name = title,
Message = message ?? title,
AppVersion = _options.AppVersion,
AppBuild = _options.AppBuild,
Environment = _options.Environment,
UserId = currentJourney?.UserId ?? _userId,
UserEmail = currentJourney?.UserEmail ?? _userEmail,
JourneyId = currentJourney?.JourneyId,
StepId = currentStep?.StepId,
Breadcrumbs = GetBreadcrumbPayloads(),
Metadata = metadata
};
_pendingItems.Enqueue(item);
AddToLocalLog(item);
LogDebug($"[{level.ToUpperInvariant()}] {title}: {message}");
}
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);
LogDebug($"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);
LogDebug($"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);
LogDebug($"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);
LogDebug($"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);
LogDebug($"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);
LogDebug($"Sent {items.Count} items, status: {response.StatusCode}");
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
LogDebug($"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;
}
///
/// Get all current breadcrumbs. Useful for viewing breadcrumbs in a debug UI.
///
public IReadOnlyList GetBreadcrumbs() => _breadcrumbs.ToArray();
private List GetBreadcrumbPayloads()
{
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);
}
///
/// Write a debug log message. Uses Debug.WriteLine if EnableDebugLogging is set,
/// otherwise Console.WriteLine if options.Debug is set.
///
private void LogDebug(string message)
{
if (IronTelemetry.EnableDebugLogging)
{
Debug.WriteLine($"[IronTelemetry] {message}");
}
else if (_options.Debug)
{
Console.WriteLine($"[IronTelemetry] {message}");
}
}
}
///
/// 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; }
}