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:
David Friedel 2025-12-25 09:10:35 +00:00
commit b5f4907ef7
11 changed files with 1855 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
bin/
obj/
*.user
*.suo
.vs/
*.DotSettings.user

30
IronTelemetry.Client.csproj Executable file
View File

@ -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>

231
IronTelemetry.cs Normal file
View File

@ -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.");
}
}
}

283
JourneyContext.cs Normal file
View File

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

21
LICENSE Normal file
View File

@ -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.

68
Models.cs Normal file
View File

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

256
OfflineQueue.cs Normal file
View File

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

157
README.md Normal file
View File

@ -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)

537
TelemetryClient.cs Normal file
View File

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

185
TelemetryExtensions.cs Normal file
View File

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

81
TelemetryOptions.cs Normal file
View File

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