561 lines
17 KiB
C#
561 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Core client for sending telemetry to IronTelemetry API.
|
|
/// </summary>
|
|
public class TelemetryClient : IDisposable
|
|
{
|
|
private readonly TelemetryOptions _options;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ConcurrentQueue<Breadcrumb> _breadcrumbs = new();
|
|
private readonly ConcurrentDictionary<string, string> _tags = new();
|
|
private readonly ConcurrentDictionary<string, object> _extras = new();
|
|
private readonly ConcurrentQueue<EnvelopeItem> _pendingItems = new();
|
|
private readonly ConcurrentQueue<EnvelopeItem> _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;
|
|
|
|
/// <summary>
|
|
/// Gets the current TelemetryClient instance (used by JourneyContext).
|
|
/// </summary>
|
|
internal static TelemetryClient? CurrentClient { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the offline queue for accessing queued items.
|
|
/// </summary>
|
|
public OfflineQueue? OfflineQueue => _offlineQueue;
|
|
|
|
/// <summary>
|
|
/// Gets all locally captured log items for viewing in AppLogView.
|
|
/// </summary>
|
|
public IReadOnlyList<EnvelopeItem> GetLocalLogItems() => _localLogQueue.ToArray();
|
|
|
|
/// <summary>
|
|
/// Clears the local log queue.
|
|
/// </summary>
|
|
public void ClearLocalLogItems()
|
|
{
|
|
while (_localLogQueue.TryDequeue(out _)) { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maximum number of items to keep in the local log queue.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log a message with a title, message, and optional metadata.
|
|
/// This is useful for debugging and informational logging without exceptions.
|
|
/// </summary>
|
|
/// <param name="level">Log level: "info", "warning", "error", "debug"</param>
|
|
/// <param name="title">Short title for the log entry</param>
|
|
/// <param name="message">Detailed message</param>
|
|
/// <param name="data">Optional additional data</param>
|
|
public void LogMessage(string level, string title, string? message = null, Dictionary<string, object>? data = null)
|
|
{
|
|
var currentJourney = JourneyContext.Current;
|
|
var currentStep = JourneyContext.CurrentStep;
|
|
|
|
var metadata = new Dictionary<string, object>(_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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start a step using the legacy API (Level 0 compatibility).
|
|
/// For Level 1, use JourneyContext.StartStep() instead.
|
|
/// </summary>
|
|
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<string, object>(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<string, object>(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<EnvelopeItem>();
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send items to the server. Returns true on success.
|
|
/// </summary>
|
|
private async Task<bool> SendItemsToServerAsync(List<EnvelopeItem> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all current breadcrumbs. Useful for viewing breadcrumbs in a debug UI.
|
|
/// </summary>
|
|
public IReadOnlyList<Breadcrumb> GetBreadcrumbs() => _breadcrumbs.ToArray();
|
|
|
|
private List<BreadcrumbPayload> 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<string, object> MergeMetadata(Dictionary<string, object>? extras, Dictionary<string, object>? journeyMetadata = null)
|
|
{
|
|
var result = new Dictionary<string, object>(_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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add an item to the local log queue for viewing in AppLogView.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Write a debug log message. Uses Debug.WriteLine if EnableDebugLogging is set,
|
|
/// otherwise Console.WriteLine if options.Debug is set.
|
|
/// </summary>
|
|
private void LogDebug(string message)
|
|
{
|
|
if (IronTelemetry.EnableDebugLogging)
|
|
{
|
|
Debug.WriteLine($"[IronTelemetry] {message}");
|
|
}
|
|
else if (_options.Debug)
|
|
{
|
|
Console.WriteLine($"[IronTelemetry] {message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Represents a telemetry item (exception, message, journey, step).
|
|
/// </summary>
|
|
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<BreadcrumbPayload> Breadcrumbs { get; set; } = [];
|
|
public Dictionary<string, object> Metadata { get; set; } = [];
|
|
public Dictionary<string, object> Data { get; set; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Breadcrumb payload for telemetry items.
|
|
/// </summary>
|
|
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<string, object>? Data { get; set; }
|
|
}
|