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
This commit is contained in:
commit
b5f4907ef7
|
|
@ -0,0 +1,6 @@
|
|||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.DotSettings.user
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<!-- NuGet Package Properties -->
|
||||
<PackageId>IronTelemetry.Client</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>David H Friedel Jr</Authors>
|
||||
<Company>MarketAlly</Company>
|
||||
<Description>Client SDK for IronTelemetry - Error Monitoring and Crash Reporting. Capture exceptions, track user journeys, and monitor application health with automatic correlation.</Description>
|
||||
<PackageTags>telemetry;error-monitoring;crash-reporting;exceptions;diagnostics;apm;observability</PackageTags>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<RepositoryUrl>https://github.com/ironservices/irontelemetry-client</RepositoryUrl>
|
||||
<PackageProjectUrl>https://www.irontelemetry.com</PackageProjectUrl>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" Condition="Exists('README.md')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Static API for IronTelemetry.
|
||||
/// Level 0: Drop-in error capture (Init + CaptureException)
|
||||
/// Level 1: Ambient journey correlation (StartJourney + StartStep)
|
||||
/// </summary>
|
||||
public static class IronTelemetry
|
||||
{
|
||||
private static TelemetryClient? _client;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initialize IronTelemetry with a DSN.
|
||||
/// </summary>
|
||||
public static void Init(string dsn)
|
||||
{
|
||||
Init(new TelemetryOptions { Dsn = dsn });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize IronTelemetry with options.
|
||||
/// </summary>
|
||||
public static void Init(TelemetryOptions options)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_client = new TelemetryClient(options);
|
||||
}
|
||||
}
|
||||
|
||||
#region Level 0 - Exception Capture
|
||||
|
||||
/// <summary>
|
||||
/// Capture an exception and send it to IronTelemetry.
|
||||
/// Automatically correlates with current journey if one exists.
|
||||
/// </summary>
|
||||
public static void CaptureException(Exception ex)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.CaptureException(ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture an exception with additional context.
|
||||
/// </summary>
|
||||
public static void CaptureException(Exception ex, Action<ExceptionContext> configure)
|
||||
{
|
||||
EnsureInitialized();
|
||||
var context = new ExceptionContext();
|
||||
configure(context);
|
||||
_client!.CaptureException(ex, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture a message.
|
||||
/// </summary>
|
||||
public static void CaptureMessage(string message, TelemetryLevel level = TelemetryLevel.Info)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.CaptureMessage(message, level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a breadcrumb to the current context.
|
||||
/// </summary>
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a breadcrumb to the current context.
|
||||
/// </summary>
|
||||
public static void AddBreadcrumb(Breadcrumb breadcrumb)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.AddBreadcrumb(breadcrumb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the current user context.
|
||||
/// </summary>
|
||||
public static void SetUser(string? id, string? email = null, string? username = null)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.SetUser(id, email, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a global tag that will be sent with all events.
|
||||
/// </summary>
|
||||
public static void SetTag(string key, string value)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.SetTag(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set extra data that will be sent with all events.
|
||||
/// </summary>
|
||||
public static void SetExtra(string key, object value)
|
||||
{
|
||||
EnsureInitialized();
|
||||
_client!.SetExtra(key, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Level 1 - Journey Correlation
|
||||
|
||||
/// <summary>
|
||||
/// Start a new journey. All telemetry within this scope will be correlated.
|
||||
/// </summary>
|
||||
/// <param name="name">The journey name (e.g., "Checkout Flow", "User Onboarding")</param>
|
||||
/// <returns>A disposable scope - dispose to end the journey</returns>
|
||||
/// <example>
|
||||
/// using (IronTelemetry.StartJourney("Checkout Flow"))
|
||||
/// {
|
||||
/// IronTelemetry.SetUser(currentUser.Id);
|
||||
///
|
||||
/// using (IronTelemetry.StartStep("Validate Cart", "business"))
|
||||
/// {
|
||||
/// ValidateCart();
|
||||
/// }
|
||||
///
|
||||
/// using (IronTelemetry.StartStep("Process Payment", "business"))
|
||||
/// {
|
||||
/// ProcessPayment();
|
||||
/// }
|
||||
/// }
|
||||
/// </example>
|
||||
public static JourneyScope StartJourney(string name)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return JourneyContext.StartJourney(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a step within the current journey.
|
||||
/// If no journey exists, one is created automatically.
|
||||
/// </summary>
|
||||
/// <param name="name">The step name (e.g., "Validate Cart", "Process Payment")</param>
|
||||
/// <param name="category">Optional category (e.g., "business", "technical", "navigation")</param>
|
||||
/// <returns>A disposable scope - dispose to end the step</returns>
|
||||
public static StepScope StartStep(string name, string? category = null)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return JourneyContext.StartStep(name, category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current journey, if any.
|
||||
/// </summary>
|
||||
public static JourneyScope? CurrentJourney => JourneyContext.Current;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step, if any.
|
||||
/// </summary>
|
||||
public static StepScope? CurrentStep => JourneyContext.CurrentStep;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current journey ID, if any.
|
||||
/// </summary>
|
||||
public static string? CurrentJourneyId => JourneyContext.CurrentJourneyId;
|
||||
|
||||
/// <summary>
|
||||
/// Set metadata on the current journey.
|
||||
/// </summary>
|
||||
public static void SetJourneyMetadata(string key, object value)
|
||||
{
|
||||
JourneyContext.SetMetadata(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark the current step as failed.
|
||||
/// </summary>
|
||||
public static void FailCurrentStep(string? reason = null)
|
||||
{
|
||||
JourneyContext.FailCurrentStep(reason);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Flush
|
||||
|
||||
/// <summary>
|
||||
/// Flush any pending events synchronously.
|
||||
/// </summary>
|
||||
public static void Flush(TimeSpan? timeout = null)
|
||||
{
|
||||
_client?.Flush(timeout ?? TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flush any pending events asynchronously.
|
||||
/// </summary>
|
||||
public static Task FlushAsync(TimeSpan? timeout = null)
|
||||
{
|
||||
return _client?.FlushAsync(timeout ?? TimeSpan.FromSeconds(5)) ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Get whether the SDK is initialized.
|
||||
/// </summary>
|
||||
public static bool IsInitialized => _client != null;
|
||||
|
||||
/// <summary>
|
||||
/// Get the underlying TelemetryClient instance.
|
||||
/// Returns null if not initialized.
|
||||
/// </summary>
|
||||
public static TelemetryClient? Client => _client;
|
||||
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
if (_client == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"IronTelemetry has not been initialized. Call IronTelemetry.Init() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient journey context that automatically flows through async calls.
|
||||
/// Level 1 integration - no manual ID passing required.
|
||||
/// </summary>
|
||||
public static class JourneyContext
|
||||
{
|
||||
private static readonly AsyncLocal<JourneyScope?> _currentJourney = new();
|
||||
private static readonly AsyncLocal<StepScope?> _currentStep = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current journey, if any.
|
||||
/// </summary>
|
||||
public static JourneyScope? Current => _currentJourney.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step, if any.
|
||||
/// </summary>
|
||||
public static StepScope? CurrentStep => _currentStep.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current journey ID, if any.
|
||||
/// </summary>
|
||||
public static string? CurrentJourneyId => _currentJourney.Value?.JourneyId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current step ID, if any.
|
||||
/// </summary>
|
||||
public static string? CurrentStepId => _currentStep.Value?.StepId;
|
||||
|
||||
/// <summary>
|
||||
/// Start a new journey. All telemetry within this scope will be correlated.
|
||||
/// </summary>
|
||||
/// <param name="name">The journey name (e.g., "Checkout Flow", "User Onboarding")</param>
|
||||
/// <returns>A disposable scope - dispose to end the journey</returns>
|
||||
public static JourneyScope StartJourney(string name)
|
||||
{
|
||||
var journey = new JourneyScope(name);
|
||||
_currentJourney.Value = journey;
|
||||
return journey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a step within the current journey.
|
||||
/// If no journey exists, one is created automatically.
|
||||
/// </summary>
|
||||
/// <param name="name">The step name (e.g., "Validate Cart", "Process Payment")</param>
|
||||
/// <param name="category">Optional category (e.g., "business", "technical", "navigation")</param>
|
||||
/// <returns>A disposable scope - dispose to end the step</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the user for the current journey.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add metadata to the current journey.
|
||||
/// </summary>
|
||||
public static void SetMetadata(string key, object value)
|
||||
{
|
||||
_currentJourney.Value?.SetMetadata(key, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark the current step as failed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an active journey scope.
|
||||
/// </summary>
|
||||
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<string, object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an active step within a journey.
|
||||
/// </summary>
|
||||
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<string, object> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Journey status for SDK tracking.
|
||||
/// </summary>
|
||||
public enum JourneyStatus
|
||||
{
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Abandoned
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step status for SDK tracking.
|
||||
/// </summary>
|
||||
public enum StepStatus
|
||||
{
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
Skipped
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry level for messages.
|
||||
/// </summary>
|
||||
public enum TelemetryLevel
|
||||
{
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Fatal
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breadcrumb level.
|
||||
/// </summary>
|
||||
public enum BreadcrumbLevel
|
||||
{
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A breadcrumb represents an event that happened before an error.
|
||||
/// </summary>
|
||||
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<string, object>? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for exception capture.
|
||||
/// </summary>
|
||||
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<string, object> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// File-based offline queue for telemetry items.
|
||||
/// Persists failed sends to disk and retries with exponential backoff.
|
||||
/// </summary>
|
||||
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<List<EnvelopeItem>, Task<bool>> _sendFunc;
|
||||
private int _retryAttempt;
|
||||
private bool _disposed;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new offline queue.
|
||||
/// </summary>
|
||||
/// <param name="sendFunc">Function to send items to server. Returns true on success.</param>
|
||||
/// <param name="queueDirectory">Directory to store queue file. Defaults to app data.</param>
|
||||
/// <param name="maxQueueSize">Maximum items to store. Oldest items dropped when exceeded.</param>
|
||||
/// <param name="enableAutoRetry">Whether to automatically retry sending queued items.</param>
|
||||
public OfflineQueue(
|
||||
Func<List<EnvelopeItem>, Task<bool>> 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of items currently in the queue.
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
var items = LoadQueue();
|
||||
return items.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue items that failed to send.
|
||||
/// </summary>
|
||||
public void Enqueue(List<EnvelopeItem> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to send all queued items.
|
||||
/// </summary>
|
||||
public async Task<bool> RetryQueuedItemsAsync()
|
||||
{
|
||||
if (!await _retrySemaphore.WaitAsync(0))
|
||||
{
|
||||
// Already retrying
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<EnvelopeItem> 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<EnvelopeItem>());
|
||||
}
|
||||
_retryAttempt = 0;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Exponential backoff - adjust retry timer
|
||||
_retryAttempt++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_retryAttempt++;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_retrySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all queued items without sending.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
SaveQueue(new List<EnvelopeItem>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all queued items (for display/export).
|
||||
/// </summary>
|
||||
public List<EnvelopeItem> GetQueuedItems()
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
return LoadQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private List<EnvelopeItem> LoadQueue()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_queueFilePath))
|
||||
{
|
||||
return new List<EnvelopeItem>();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_queueFilePath);
|
||||
return JsonSerializer.Deserialize<List<EnvelopeItem>>(json, JsonOptions)
|
||||
?? new List<EnvelopeItem>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<EnvelopeItem>();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveQueue(List<EnvelopeItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for offline queue behavior.
|
||||
/// </summary>
|
||||
public class OfflineQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Directory to store queue files. Defaults to LocalApplicationData/IronTelemetry/Queue.
|
||||
/// </summary>
|
||||
public string? QueueDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of items to store in the queue. Oldest items dropped when exceeded.
|
||||
/// Default: 1000
|
||||
/// </summary>
|
||||
public int MaxQueueSize { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically retry sending queued items in the background.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool EnableAutoRetry { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Initial retry delay after a failed send. Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry delay (for exponential backoff). Default: 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
|
@ -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<string, object> { ["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)
|
||||
|
|
@ -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;
|
||||
|
||||
/// <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 = 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<string, object>(_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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
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<string, object>(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<string, object>(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<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);
|
||||
|
||||
if (_options.Debug)
|
||||
{
|
||||
Console.WriteLine($"[IronTelemetry] 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);
|
||||
|
||||
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<BreadcrumbPayload> 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<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>
|
||||
/// 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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for IronTelemetry integration.
|
||||
/// </summary>
|
||||
public static class TelemetryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configure global unhandled exception handling for console applications.
|
||||
/// </summary>
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture an exception and return it (for throw expressions).
|
||||
/// </summary>
|
||||
public static Exception Capture(this Exception ex)
|
||||
{
|
||||
IronTelemetry.CaptureException(ex);
|
||||
return ex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture an exception with context and return it.
|
||||
/// </summary>
|
||||
public static Exception Capture(this Exception ex, Action<ExceptionContext> configure)
|
||||
{
|
||||
IronTelemetry.CaptureException(ex, configure);
|
||||
return ex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a step scope that automatically tracks duration.
|
||||
/// </summary>
|
||||
public static StepScope TimeStep(this TelemetryClient client, string name, string? category = null)
|
||||
{
|
||||
return JourneyContext.StartStep(name, category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an action within a tracked step.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an async action within a tracked step.
|
||||
/// </summary>
|
||||
public static async Task TrackStepAsync(string name, Func<Task> action, string? category = null)
|
||||
{
|
||||
using var step = IronTelemetry.StartStep(name, category);
|
||||
try
|
||||
{
|
||||
await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
step.Fail(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a function within a tracked step and return the result.
|
||||
/// </summary>
|
||||
public static T TrackStep<T>(string name, Func<T> func, string? category = null)
|
||||
{
|
||||
using var step = IronTelemetry.StartStep(name, category);
|
||||
try
|
||||
{
|
||||
return func();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
step.Fail(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an async function within a tracked step and return the result.
|
||||
/// </summary>
|
||||
public static async Task<T> TrackStepAsync<T>(string name, Func<Task<T>> func, string? category = null)
|
||||
{
|
||||
using var step = IronTelemetry.StartStep(name, category);
|
||||
try
|
||||
{
|
||||
return await func();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
step.Fail(ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add Activity trace context to the current telemetry.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a journey and return the result.
|
||||
/// </summary>
|
||||
public static T RunJourney<T>(string name, Func<T> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute an async journey and return the result.
|
||||
/// </summary>
|
||||
public static async Task<T> RunJourneyAsync<T>(string name, Func<Task<T>> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
namespace IronTelemetry.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for IronTelemetry SDK.
|
||||
/// </summary>
|
||||
public class TelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSN (Data Source Name) for your project.
|
||||
/// Format: https://{public_key}@irontelemetry.com
|
||||
/// </summary>
|
||||
public string Dsn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The environment (e.g., "production", "staging", "development").
|
||||
/// </summary>
|
||||
public string? Environment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The application version.
|
||||
/// </summary>
|
||||
public string? AppVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The application build number.
|
||||
/// </summary>
|
||||
public string? AppBuild { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample rate for events (0.0 to 1.0). Default is 1.0 (100%).
|
||||
/// </summary>
|
||||
public double SampleRate { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of breadcrumbs to store. Default is 100.
|
||||
/// </summary>
|
||||
public int MaxBreadcrumbs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Enable debug logging. Default is false.
|
||||
/// </summary>
|
||||
public bool Debug { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to filter exceptions before sending.
|
||||
/// Return false to skip sending the exception.
|
||||
/// </summary>
|
||||
public Func<Exception, bool>? BeforeSend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attach stack trace to messages. Default is false.
|
||||
/// </summary>
|
||||
public bool AttachStacktrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for sending events. Default is 5 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan SendTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Custom HTTP handler for testing or proxy scenarios.
|
||||
/// </summary>
|
||||
public HttpMessageHandler? HttpHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable offline queue for failed sends. Default is true.
|
||||
/// When enabled, failed sends are persisted to disk and retried automatically.
|
||||
/// </summary>
|
||||
public bool EnableOfflineQueue { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Directory to store offline queue. Defaults to LocalApplicationData/IronTelemetry/Queue.
|
||||
/// </summary>
|
||||
public string? OfflineQueueDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items to store in offline queue. Oldest items dropped when exceeded.
|
||||
/// Default: 1000
|
||||
/// </summary>
|
||||
public int MaxOfflineQueueSize { get; set; } = 1000;
|
||||
}
|
||||
Loading…
Reference in New Issue