diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d3eb97 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + dotnet-version: ['8.0.x', '9.0.x'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run SDK tests + run: dotnet test tests/LogTide.SDK.Tests.csproj --no-build --configuration Release --verbosity normal + + - name: Run Serilog tests + run: dotnet test tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj --no-build --configuration Release --verbosity normal + + vulnerability-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Check for vulnerable packages + run: dotnet list package --vulnerable --include-transitive 2>&1 | tee /dev/stderr | grep -q "no vulnerable packages" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6325604 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release to NuGet + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run SDK tests + run: dotnet test tests/LogTide.SDK.Tests.csproj --no-build --configuration Release --verbosity normal + + - name: Run Serilog tests + run: dotnet test tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj --no-build --configuration Release --verbosity normal + + - name: Pack LogTide.SDK + run: dotnet pack LogTide.SDK.csproj --no-build --configuration Release -p:PackageVersion=${{ steps.version.outputs.VERSION }} --output ./nupkgs + + - name: Pack LogTide.SDK.Serilog + run: dotnet pack Serilog/LogTide.SDK.Serilog.csproj --no-build --configuration Release -p:PackageVersion=${{ steps.version.outputs.VERSION }} --output ./nupkgs + + - name: Push to NuGet + run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: ./nupkgs/*.nupkg diff --git a/.gitignore b/.gitignore index 86c5795..6c31611 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ coverage*.xml coverage/ TestResults/ +# Git worktrees +.worktrees/ + # OS generated files .DS_Store .DS_Store? diff --git a/Breadcrumbs/Breadcrumb.cs b/Breadcrumbs/Breadcrumb.cs new file mode 100644 index 0000000..26efae9 --- /dev/null +++ b/Breadcrumbs/Breadcrumb.cs @@ -0,0 +1,10 @@ +namespace LogTide.SDK.Breadcrumbs; + +public sealed class Breadcrumb +{ + public string Type { get; set; } = "custom"; + public string Message { get; set; } = string.Empty; + public string? Level { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public Dictionary Data { get; set; } = new(); +} diff --git a/Breadcrumbs/BreadcrumbBuffer.cs b/Breadcrumbs/BreadcrumbBuffer.cs new file mode 100644 index 0000000..27c5137 --- /dev/null +++ b/Breadcrumbs/BreadcrumbBuffer.cs @@ -0,0 +1,24 @@ +namespace LogTide.SDK.Breadcrumbs; + +internal sealed class BreadcrumbBuffer +{ + private readonly int _maxSize; + private readonly Queue _queue = new(); + private readonly object _lock = new(); + + public BreadcrumbBuffer(int maxSize = 50) => _maxSize = maxSize; + + public void Add(Breadcrumb breadcrumb) + { + lock (_lock) + { + if (_queue.Count >= _maxSize) _queue.Dequeue(); + _queue.Enqueue(breadcrumb); + } + } + + public IReadOnlyList GetAll() + { + lock (_lock) { return _queue.ToArray(); } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index cdf8145..be9950c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.3] - 2026-03-23 + +### Added + +- **W3C traceparent** distributed tracing (replaces `X-Trace-Id`) +- **AsyncLocal `LogTideScope`** for ambient trace context across async flows +- **Span tracking** with `StartSpan`/`FinishSpan` and OpenTelemetry-compatible OTLP export +- **Breadcrumbs** — `Breadcrumb` model and ring-buffer `BreadcrumbBuffer` attached to scopes +- **Composable transport layer** — `ILogTransport`, `ISpanTransport`, `BatchTransport`, `LogTideHttpTransport`, `OtlpHttpTransport` +- **`ILogTideClient` interface** for DI and testability (with `NSubstitute`) +- **Integration system** — `IIntegration` interface, `GlobalErrorIntegration` for unhandled exceptions +- **Serilog sink** — new `LogTide.SDK.Serilog` project with `LogTideSink` and `WriteTo.LogTide()` extension +- **DSN connection string** support via `ClientOptions.FromDsn()` and `LogTideClient.FromDsn()` +- `SpanId`, `SessionId` fields on `LogEntry` +- `ServiceName`, `TracesSampleRate`, `Integrations` on `ClientOptions` +- `LogTideErrorHandlerMiddleware` for catching unhandled exceptions in ASP.NET Core +- Sensitive header filtering in middleware (`Authorization`, `Cookie`, `X-API-Key`, etc.) +- `NuGetAudit` enabled on all projects +- `FinishSpan` added to `ILogTideClient` interface + +### Changed + +- Target frameworks: `net8.0;net9.0` (dropped net6.0, net7.0) +- `LangVersion` updated to 13 +- `LogTideClient` rewritten as thin facade over `BatchTransport` with scope enrichment +- `LogTideClient` is now `sealed` and implements `ILogTideClient` +- Middleware now uses W3C `traceparent` header (fallback to `X-Trace-Id`) +- Middleware resolved from DI (`ILogTideClient`) instead of `LogTideMiddlewareOptions.Client` +- `AddLogTide()` now registers `IHttpClientFactory` named client +- `System.Text.Json` updated to 9.0.0, `Microsoft.Extensions.Http` to 9.0.0 + +### Fixed + +- **Circuit breaker HalfOpen** now allows exactly one probe (was allowing unlimited) +- **`DisposeAsync`** was setting `_disposed=true` before flushing, silently dropping all buffered logs +- **`Dispose()`** was not flushing buffered logs at all +- **Double-counting `LogsDropped`** when circuit breaker rejected a batch +- **`RecordFailure` called per retry attempt** instead of once — circuit breaker tripped prematurely +- **`FromDsn` HttpClient** was never disposed (resource leak) +- **`IHttpClientFactory` constructor** created 3 separate `HttpClient` instances instead of 1 +- **`W3CTraceContext.Parse`** now validates hex characters, flags field, and rejects all-zeros trace/span IDs per W3C spec +- **Duplicate `X-API-Key` header** when `FromDsn` passed pre-configured HttpClient to constructor +- **Span leak in middleware** when `LogRequest` threw before `try` block — now wrapped in `try/finally` +- **Dispose race condition** — non-atomic check-then-act on `_disposed` replaced with `Interlocked.CompareExchange` +- **Sync-over-async deadlock** — `Dispose()` now uses `Task.Run` to avoid `SynchronizationContext` capture +- **`GlobalErrorIntegration._client`** visibility across threads (now `volatile`) +- Removed vulnerable `System.Net.Http 4.3.4` and `System.Text.RegularExpressions 4.3.1` from test project +- Removed vulnerable `Microsoft.AspNetCore.Http.Abstractions 2.2.0` explicit pin (uses `FrameworkReference` now) +- Removed unnecessary `System.Text.Encodings.Web` explicit pin + +### Removed + +- `SetTraceId()`, `GetTraceId()`, `WithTraceId()`, `WithNewTraceId()` — use `LogTideScope.Create(traceId)` +- `LogTideMiddlewareOptions.Client` property — client resolved from DI +- `LogTideMiddlewareOptions.TraceIdHeader` property — W3C `traceparent` is now the standard +- `Moq` dependency — replaced with `NSubstitute` + ## [0.1.0] - 2026-01-13 ### Added @@ -27,4 +84,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full async/await support - Support for .NET 6.0, 7.0, and 8.0 +[0.8.3]: https://github.com/logtide-dev/logtide-sdk-csharp/compare/v0.1.0...v0.8.3 [0.1.0]: https://github.com/logtide-dev/logtide-sdk-csharp/releases/tag/v0.1.0 diff --git a/Core/ILogTideClient.cs b/Core/ILogTideClient.cs new file mode 100644 index 0000000..4b87d7a --- /dev/null +++ b/Core/ILogTideClient.cs @@ -0,0 +1,25 @@ +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Enums; +using LogTide.SDK.Models; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Core; + +public interface ILogTideClient : IDisposable, IAsyncDisposable +{ + void Log(LogEntry entry); + void Debug(string service, string message, Dictionary? metadata = null); + void Info(string service, string message, Dictionary? metadata = null); + void Warn(string service, string message, Dictionary? metadata = null); + void Error(string service, string message, Dictionary? metadata = null); + void Error(string service, string message, Exception exception); + void Critical(string service, string message, Dictionary? metadata = null); + void Critical(string service, string message, Exception exception); + Task FlushAsync(CancellationToken cancellationToken = default); + Span StartSpan(string name, string? parentSpanId = null); + void FinishSpan(Span span, SpanStatus status = SpanStatus.Ok); + void AddBreadcrumb(Breadcrumb breadcrumb); + ClientMetrics GetMetrics(); + void ResetMetrics(); + CircuitState GetCircuitBreakerState(); +} diff --git a/Core/LogTideClient.cs b/Core/LogTideClient.cs new file mode 100644 index 0000000..091abe0 --- /dev/null +++ b/Core/LogTideClient.cs @@ -0,0 +1,238 @@ +using System.Text.Json; +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Enums; +using LogTide.SDK.Exceptions; +using LogTide.SDK.Internal; +using LogTide.SDK.Models; +using LogTide.SDK.Tracing; +using LogTide.SDK.Transport; + +namespace LogTide.SDK.Core; + +public sealed class LogTideClient : ILogTideClient +{ + private readonly ClientOptions _options; + private readonly BatchTransport _transport; + private readonly SpanManager _spanManager = new(); + private readonly HttpClient _queryHttpClient; + private readonly bool _ownsHttpClient; + private int _disposed; // 0 = not disposed, 1 = disposed; accessed via Interlocked + + /// + /// Creates a new LogTide client from a DSN string. + /// + public static LogTideClient FromDsn(string dsn, ClientOptions? baseOptions = null) + { + var opts = baseOptions ?? new ClientOptions(); + opts.Dsn = dsn; + opts.Resolve(); + var httpClient = new HttpClient + { + BaseAddress = new Uri(opts.ApiUrl.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(opts.HttpTimeoutSeconds) + }; + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", opts.ApiKey); + return new LogTideClient(opts, + new LogTideHttpTransport(httpClient), + new OtlpHttpTransport(httpClient, opts.ServiceName), + httpClient, + ownsHttpClient: true); + } + + /// + /// Creates a new LogTide client with an IHttpClientFactory (for DI scenarios). + /// + public LogTideClient(ClientOptions options, IHttpClientFactory httpClientFactory) + { + var httpClient = httpClientFactory.CreateClient("LogTide"); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _options.Resolve(); + _transport = new BatchTransport( + new LogTideHttpTransport(httpClient), + new OtlpHttpTransport(httpClient, options.ServiceName), + options); + _queryHttpClient = httpClient; + _ownsHttpClient = false; // factory manages lifetime + foreach (var integration in options.Integrations) + integration.Setup(this); + } + + /// + /// Creates a new LogTide client for testing or direct construction. + /// + internal LogTideClient(ClientOptions options, ILogTransport logTransport, ISpanTransport? spanTransport, + HttpClient? queryHttpClient = null, bool ownsHttpClient = false) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _options.Resolve(); + _transport = new BatchTransport(logTransport, spanTransport, options); + _ownsHttpClient = ownsHttpClient || queryHttpClient == null; + _queryHttpClient = queryHttpClient ?? new HttpClient { BaseAddress = new Uri(options.ApiUrl.TrimEnd('/')) }; + if (!_queryHttpClient.DefaultRequestHeaders.Contains("X-API-Key") && !string.IsNullOrEmpty(options.ApiKey)) + _queryHttpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", options.ApiKey); + foreach (var integration in options.Integrations) + integration.Setup(this); + } + + public void Log(LogEntry entry) + { + if (Volatile.Read(ref _disposed) == 1) return; + var scope = LogTideScope.Current; + if (string.IsNullOrEmpty(entry.TraceId)) + entry.TraceId = scope?.TraceId ?? (_options.AutoTraceId ? W3CTraceContext.GenerateTraceId() : null); + if (string.IsNullOrEmpty(entry.SpanId)) + entry.SpanId = scope?.SpanId; + if (string.IsNullOrEmpty(entry.SessionId)) + entry.SessionId = scope?.SessionId; + + if (scope != null) + { + var crumbs = scope.GetBreadcrumbs(); + if (crumbs.Count > 0) entry.Metadata.TryAdd("breadcrumbs", crumbs); + } + foreach (var kvp in _options.GlobalMetadata) + entry.Metadata.TryAdd(kvp.Key, kvp.Value); + + _transport.Enqueue(entry); + } + + public void Debug(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Debug, Message = message, Metadata = metadata ?? new() }); + public void Info(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Info, Message = message, Metadata = metadata ?? new() }); + public void Warn(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Warn, Message = message, Metadata = metadata ?? new() }); + public void Error(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Error, Message = message, Metadata = metadata ?? new() }); + public void Error(string service, string message, Exception exception) + => Log(new LogEntry { Service = service, Level = LogLevel.Error, Message = message, Metadata = new() { ["error"] = SerializeException(exception) } }); + public void Critical(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Critical, Message = message, Metadata = metadata ?? new() }); + public void Critical(string service, string message, Exception exception) + => Log(new LogEntry { Service = service, Level = LogLevel.Critical, Message = message, Metadata = new() { ["error"] = SerializeException(exception) } }); + + public Task FlushAsync(CancellationToken cancellationToken = default) => _transport.FlushAsync(cancellationToken); + + public Span StartSpan(string name, string? parentSpanId = null) + { + var traceId = LogTideScope.Current?.TraceId ?? W3CTraceContext.GenerateTraceId(); + var span = _spanManager.StartSpan(name, traceId, parentSpanId); + if (LogTideScope.Current != null) LogTideScope.Current.SpanId = span.SpanId; + return span; + } + + public void FinishSpan(Span span, SpanStatus status = SpanStatus.Ok) + { + if (_spanManager.TryFinishSpan(span.SpanId, status, out var finished) && finished != null) + _transport.EnqueueSpan(finished); + } + + public void AddBreadcrumb(Breadcrumb breadcrumb) + => LogTideScope.Current?.AddBreadcrumb(breadcrumb); + + public ClientMetrics GetMetrics() => _transport.GetMetrics(); + public void ResetMetrics() => _transport.ResetMetrics(); + public CircuitState GetCircuitBreakerState() => _transport.CircuitBreakerState; + + #region Query Methods + + public async Task QueryAsync(QueryOptions options, CancellationToken cancellationToken = default) + { + var queryParams = new List(); + + if (!string.IsNullOrEmpty(options.Service)) + queryParams.Add($"service={Uri.EscapeDataString(options.Service)}"); + if (options.Level.HasValue) + queryParams.Add($"level={options.Level.Value.ToApiString()}"); + if (options.From.HasValue) + queryParams.Add($"from={Uri.EscapeDataString(options.From.Value.ToString("O"))}"); + if (options.To.HasValue) + queryParams.Add($"to={Uri.EscapeDataString(options.To.Value.ToString("O"))}"); + if (!string.IsNullOrEmpty(options.Query)) + queryParams.Add($"q={Uri.EscapeDataString(options.Query)}"); + queryParams.Add($"limit={options.Limit}"); + queryParams.Add($"offset={options.Offset}"); + + var url = $"/api/v1/logs?{string.Join("&", queryParams)}"; + using var response = await _queryHttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new ApiException((int)response.StatusCode, errorBody); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(json, JsonConfig.Options); + return result ?? new LogsResponse(); + } + + public async Task> GetByTraceIdAsync(string traceId, CancellationToken cancellationToken = default) + { + var url = $"/api/v1/logs/trace/{Uri.EscapeDataString(traceId)}"; + using var response = await _queryHttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new ApiException((int)response.StatusCode, errorBody); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(json, JsonConfig.Options); + return result?.Logs ?? new List(); + } + + public async Task GetAggregatedStatsAsync( + AggregatedStatsOptions options, + CancellationToken cancellationToken = default) + { + var queryParams = new List + { + $"from={Uri.EscapeDataString(options.From.ToString("O"))}", + $"to={Uri.EscapeDataString(options.To.ToString("O"))}", + $"interval={Uri.EscapeDataString(options.Interval)}" + }; + + if (!string.IsNullOrEmpty(options.Service)) + queryParams.Add($"service={Uri.EscapeDataString(options.Service)}"); + + var url = $"/api/v1/logs/aggregated?{string.Join("&", queryParams)}"; + using var response = await _queryHttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + throw new ApiException((int)response.StatusCode, errorBody); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var result = JsonSerializer.Deserialize(json, JsonConfig.Options); + return result ?? new AggregatedStatsResponse(); + } + + #endregion + + private static Dictionary SerializeException(Exception ex) + { + var r = new Dictionary { ["type"] = ex.GetType().FullName, ["message"] = ex.Message, ["stack"] = ex.StackTrace }; + if (ex.InnerException != null) r["cause"] = SerializeException(ex.InnerException); + return r; + } + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + foreach (var i in _options.Integrations) i.Teardown(); + _transport.Dispose(); + if (_ownsHttpClient) _queryHttpClient.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + foreach (var i in _options.Integrations) i.Teardown(); + await _transport.DisposeAsync().ConfigureAwait(false); + if (_ownsHttpClient) _queryHttpClient.Dispose(); + } +} diff --git a/Core/LogTideScope.cs b/Core/LogTideScope.cs new file mode 100644 index 0000000..b63a866 --- /dev/null +++ b/Core/LogTideScope.cs @@ -0,0 +1,40 @@ +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Core; + +public sealed class LogTideScope : IDisposable +{ + private static readonly AsyncLocal _current = new(); + + public static LogTideScope? Current => _current.Value; + + public string TraceId { get; } + public string? SpanId { get; internal set; } + public string? SessionId { get; set; } + + private readonly BreadcrumbBuffer _breadcrumbs = new(maxSize: 50); + private readonly LogTideScope? _previous; + + private LogTideScope(string traceId) + { + TraceId = traceId; + _previous = _current.Value; + _current.Value = this; + } + + public static LogTideScope Create(string? traceId = null) => + new(traceId ?? W3CTraceContext.GenerateTraceId()); + + public void AddBreadcrumb(Breadcrumb breadcrumb) => _breadcrumbs.Add(breadcrumb); + public IReadOnlyList GetBreadcrumbs() => _breadcrumbs.GetAll(); + + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _current.Value = _previous; + } +} diff --git a/Integrations/GlobalErrorIntegration.cs b/Integrations/GlobalErrorIntegration.cs new file mode 100644 index 0000000..3212ae9 --- /dev/null +++ b/Integrations/GlobalErrorIntegration.cs @@ -0,0 +1,37 @@ +using LogTide.SDK.Core; + +namespace LogTide.SDK.Integrations; + +public sealed class GlobalErrorIntegration : IIntegration +{ + private volatile ILogTideClient? _client; + public string Name => "GlobalError"; + + public void Setup(ILogTideClient client) + { + _client = client; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + } + + public void Teardown() + { + AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; + TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception ex) + _client?.Critical("global", "Unhandled exception", ex); + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + _client?.Error("global", "Unobserved task exception", e.Exception); + e.SetObserved(); + } + + internal void SimulateUnobservedTaskException(AggregateException ex) + => OnUnobservedTaskException(null, new UnobservedTaskExceptionEventArgs(ex)); +} diff --git a/Integrations/IIntegration.cs b/Integrations/IIntegration.cs new file mode 100644 index 0000000..7b0310a --- /dev/null +++ b/Integrations/IIntegration.cs @@ -0,0 +1,10 @@ +using LogTide.SDK.Core; + +namespace LogTide.SDK.Integrations; + +public interface IIntegration +{ + string Name { get; } + void Setup(ILogTideClient client); + void Teardown(); +} diff --git a/Internal/CircuitBreaker.cs b/Internal/CircuitBreaker.cs index 9926d74..953215f 100644 --- a/Internal/CircuitBreaker.cs +++ b/Internal/CircuitBreaker.cs @@ -14,6 +14,7 @@ internal class CircuitBreaker private CircuitState _state = CircuitState.Closed; private int _failureCount; private DateTime? _lastFailureTime; + private bool _halfOpenProbePending; /// /// Creates a new circuit breaker. @@ -49,6 +50,7 @@ public void RecordSuccess() lock (_lock) { _failureCount = 0; + _halfOpenProbePending = false; _state = CircuitState.Closed; } } @@ -61,12 +63,9 @@ public void RecordFailure() lock (_lock) { _failureCount++; + _halfOpenProbePending = false; _lastFailureTime = DateTime.UtcNow; - - if (_failureCount >= _threshold) - { - _state = CircuitState.Open; - } + if (_failureCount >= _threshold) _state = CircuitState.Open; } } @@ -78,7 +77,12 @@ public bool CanAttempt() lock (_lock) { UpdateState(); - return _state != CircuitState.Open; + if (_state == CircuitState.Closed) return true; + if (_state == CircuitState.Open) return false; + // HalfOpen: allow exactly one probe + if (_halfOpenProbePending) return false; + _halfOpenProbePending = true; + return true; } } diff --git a/LogTide.SDK.csproj b/LogTide.SDK.csproj index 528f32e..314b0c4 100644 --- a/LogTide.SDK.csproj +++ b/LogTide.SDK.csproj @@ -1,14 +1,14 @@ - net6.0;net7.0;net8.0 + net8.0;net9.0 enable enable - 10 - + 13 + LogTide.SDK - 0.1.0 + 0.8.3 LogTide LogTide Official .NET SDK for LogTide - A powerful log management platform. Features automatic batching, retry logic, circuit breaker, distributed tracing, and ASP.NET Core middleware. @@ -19,26 +19,29 @@ git logging;log-management;observability;monitoring;distributed-tracing;aspnetcore;middleware README.md - + true false $(NoWarn);CS1591 + true + moderate + + - - - - + + + diff --git a/LogTide.SDK.sln b/LogTide.SDK.sln index 892263a..ea3992a 100644 --- a/LogTide.SDK.sln +++ b/LogTide.SDK.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -7,19 +7,78 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogTide.SDK", "LogTide.SDK. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogTide.SDK.Tests", "tests\LogTide.SDK.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Serilog", "Serilog", "{BD735133-11AC-1568-E807-9D4EDCF8CF5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogTide.SDK.Serilog", "Serilog\LogTide.SDK.Serilog.csproj", "{DC51A3D6-24B5-4660-B7A2-5C9393EDD336}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogTide.SDK.Serilog.Tests", "tests\LogTide.SDK.Serilog.Tests\LogTide.SDK.Serilog.Tests.csproj", "{1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|x64.Build.0 = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Debug|x86.Build.0 = Debug|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|Any CPU.Build.0 = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|x64.ActiveCfg = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|x64.Build.0 = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|x86.ActiveCfg = Release|Any CPU + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336}.Release|x86.Build.0 = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|x64.Build.0 = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Debug|x86.Build.0 = Debug|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|Any CPU.Build.0 = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|x64.ActiveCfg = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|x64.Build.0 = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|x86.ActiveCfg = Release|Any CPU + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DC51A3D6-24B5-4660-B7A2-5C9393EDD336} = {BD735133-11AC-1568-E807-9D4EDCF8CF5D} + {1CF1E0F3-B51C-4793-B2FA-D707FEE034ED} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/LogTideClient.cs b/LogTideClient.cs deleted file mode 100644 index faad2d4..0000000 --- a/LogTideClient.cs +++ /dev/null @@ -1,691 +0,0 @@ -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using LogTide.SDK.Enums; -using LogTide.SDK.Exceptions; -using LogTide.SDK.Internal; -using LogTide.SDK.Models; - -namespace LogTide.SDK; - -/// -/// Main LogTide SDK client for sending and querying logs. -/// -/// -/// This client provides automatic batching, retry logic with exponential backoff, -/// circuit breaker pattern for fault tolerance, and comprehensive query capabilities. -/// -public class LogTideClient : IDisposable, IAsyncDisposable -{ - private readonly ClientOptions _options; - private readonly HttpClient _httpClient; - private readonly CircuitBreaker _circuitBreaker; - private readonly List _buffer = new(); - private readonly object _bufferLock = new(); - private readonly object _metricsLock = new(); - private readonly Timer _flushTimer; - private readonly List _latencyWindow = new(); - - private ClientMetrics _metrics = new(); - private string? _currentTraceId; - private bool _disposed; - - /// - /// Creates a new LogTide client. - /// - /// Client configuration options. - public LogTideClient(ClientOptions options) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - - // Initialize HTTP client - _httpClient = new HttpClient - { - BaseAddress = new Uri(options.ApiUrl.TrimEnd('/')), - Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds) - }; - _httpClient.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey); - - // Initialize circuit breaker - _circuitBreaker = new CircuitBreaker( - options.CircuitBreakerThreshold, - options.CircuitBreakerResetMs - ); - - // Start flush timer - _flushTimer = new Timer( - _ => _ = FlushAsync(), - null, - options.FlushIntervalMs, - options.FlushIntervalMs - ); - - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Client initialized: {options.ApiUrl}"); - } - } - - /// - /// Creates a new LogTide client with an existing HttpClient (for DI scenarios). - /// - public LogTideClient(ClientOptions options, HttpClient httpClient) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - - _httpClient.DefaultRequestHeaders.Add("X-API-Key", options.ApiKey); - - _circuitBreaker = new CircuitBreaker( - options.CircuitBreakerThreshold, - options.CircuitBreakerResetMs - ); - - _flushTimer = new Timer( - _ => _ = FlushAsync(), - null, - options.FlushIntervalMs, - options.FlushIntervalMs - ); - - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Client initialized with custom HttpClient: {options.ApiUrl}"); - } - } - - #region Trace ID Context - - /// - /// Sets the trace ID for subsequent logs. - /// - /// Trace ID or null to clear. - public void SetTraceId(string? traceId) - { - _currentTraceId = traceId; - } - - /// - /// Gets the current trace ID. - /// - public string? GetTraceId() => _currentTraceId; - - /// - /// Executes an action with a specific trace ID context. - /// - /// Trace ID to use. - /// Action to execute. - public void WithTraceId(string traceId, Action action) - { - var previousTraceId = _currentTraceId; - _currentTraceId = traceId; - try - { - action(); - } - finally - { - _currentTraceId = previousTraceId; - } - } - - /// - /// Executes a function with a specific trace ID context. - /// - public T WithTraceId(string traceId, Func func) - { - var previousTraceId = _currentTraceId; - _currentTraceId = traceId; - try - { - return func(); - } - finally - { - _currentTraceId = previousTraceId; - } - } - - /// - /// Executes an action with a new auto-generated trace ID. - /// - public void WithNewTraceId(Action action) - { - WithTraceId(Guid.NewGuid().ToString(), action); - } - - /// - /// Executes a function with a new auto-generated trace ID. - /// - public T WithNewTraceId(Func func) - { - return WithTraceId(Guid.NewGuid().ToString(), func); - } - - #endregion - - #region Logging Methods - - /// - /// Logs a custom entry. - /// - /// Log entry to send. - /// Thrown when the buffer is full. - public void Log(LogEntry entry) - { - if (_disposed) return; - - // Apply trace ID - if (string.IsNullOrEmpty(entry.TraceId)) - { - if (!string.IsNullOrEmpty(_currentTraceId)) - { - entry.TraceId = _currentTraceId; - } - else if (_options.AutoTraceId) - { - entry.TraceId = Guid.NewGuid().ToString(); - } - } - - // Merge global metadata - if (_options.GlobalMetadata.Count > 0) - { - foreach (var kvp in _options.GlobalMetadata) - { - if (!entry.Metadata.ContainsKey(kvp.Key)) - { - entry.Metadata[kvp.Key] = kvp.Value; - } - } - } - - lock (_bufferLock) - { - if (_buffer.Count >= _options.MaxBufferSize) - { - lock (_metricsLock) - { - _metrics.LogsDropped++; - } - - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Buffer full, dropping log: {entry.Message}"); - } - - throw new BufferFullException(); - } - - _buffer.Add(entry); - - if (_buffer.Count >= _options.BatchSize) - { - _ = FlushAsync(); - } - } - } - - /// - /// Logs a debug message. - /// - public void Debug(string service, string message, Dictionary? metadata = null) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Debug, - Message = message, - Metadata = metadata ?? new() - }); - } - - /// - /// Logs an info message. - /// - public void Info(string service, string message, Dictionary? metadata = null) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Info, - Message = message, - Metadata = metadata ?? new() - }); - } - - /// - /// Logs a warning message. - /// - public void Warn(string service, string message, Dictionary? metadata = null) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Warn, - Message = message, - Metadata = metadata ?? new() - }); - } - - /// - /// Logs an error message. - /// - public void Error(string service, string message, Dictionary? metadata = null) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Error, - Message = message, - Metadata = metadata ?? new() - }); - } - - /// - /// Logs an error message with exception details. - /// - public void Error(string service, string message, Exception exception) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Error, - Message = message, - Metadata = new Dictionary - { - ["error"] = SerializeException(exception) - } - }); - } - - /// - /// Logs a critical message. - /// - public void Critical(string service, string message, Dictionary? metadata = null) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Critical, - Message = message, - Metadata = metadata ?? new() - }); - } - - /// - /// Logs a critical message with exception details. - /// - public void Critical(string service, string message, Exception exception) - { - Log(new LogEntry - { - Service = service, - Level = LogLevel.Critical, - Message = message, - Metadata = new Dictionary - { - ["error"] = SerializeException(exception) - } - }); - } - - #endregion - - #region Flush - - /// - /// Flushes buffered logs to the LogTide API. - /// - public async Task FlushAsync(CancellationToken cancellationToken = default) - { - if (_disposed) return; - - List logsToSend; - - lock (_bufferLock) - { - if (_buffer.Count == 0) return; - - logsToSend = new List(_buffer); - _buffer.Clear(); - } - - await SendLogsWithRetryAsync(logsToSend, cancellationToken); - } - - private async Task SendLogsWithRetryAsync(List logs, CancellationToken cancellationToken) - { - var attempt = 0; - var delay = _options.RetryDelayMs; - Exception? lastException = null; - - while (attempt <= _options.MaxRetries) - { - try - { - // Check circuit breaker - if (!_circuitBreaker.CanAttempt()) - { - if (_options.Debug) - { - Console.WriteLine("[LogTide] Circuit breaker OPEN, skipping send"); - } - - lock (_metricsLock) - { - _metrics.LogsDropped += logs.Count; - _metrics.CircuitBreakerTrips++; - } - - throw new CircuitBreakerOpenException(); - } - - var stopwatch = Stopwatch.StartNew(); - await SendLogsAsync(logs, cancellationToken); - stopwatch.Stop(); - - // Record success - _circuitBreaker.RecordSuccess(); - UpdateLatency(stopwatch.Elapsed.TotalMilliseconds); - - lock (_metricsLock) - { - _metrics.LogsSent += logs.Count; - } - - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Sent {logs.Count} logs ({stopwatch.ElapsedMilliseconds}ms)"); - } - - return; - } - catch (CircuitBreakerOpenException) - { - break; - } - catch (Exception ex) - { - lastException = ex; - attempt++; - _circuitBreaker.RecordFailure(); - - lock (_metricsLock) - { - _metrics.Errors++; - if (attempt <= _options.MaxRetries) - { - _metrics.Retries++; - } - } - - if (attempt > _options.MaxRetries) - { - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Failed to send logs after {attempt} attempts: {ex.Message}"); - } - break; - } - - if (_options.Debug) - { - Console.WriteLine($"[LogTide] Retry {attempt}/{_options.MaxRetries} in {delay}ms: {ex.Message}"); - } - - await Task.Delay(delay, cancellationToken); - delay *= 2; // Exponential backoff - } - } - - // All retries failed - lock (_metricsLock) - { - _metrics.LogsDropped += logs.Count; - } - - if (_circuitBreaker.State == CircuitState.Open) - { - lock (_metricsLock) - { - _metrics.CircuitBreakerTrips++; - } - } - } - - private async Task SendLogsAsync(List logs, CancellationToken cancellationToken) - { - var serializableLogs = logs.Select(SerializableLogEntry.FromLogEntry).ToList(); - var payload = new { logs = serializableLogs }; - var json = JsonSerializer.Serialize(payload, JsonConfig.Options); - - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var response = await _httpClient.PostAsync("/api/v1/ingest", content, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new ApiException((int)response.StatusCode, errorBody); - } - } - - #endregion - - #region Query Methods - - /// - /// Queries logs with filters. - /// - public async Task QueryAsync(QueryOptions options, CancellationToken cancellationToken = default) - { - var queryParams = new List(); - - if (!string.IsNullOrEmpty(options.Service)) - queryParams.Add($"service={Uri.EscapeDataString(options.Service)}"); - if (options.Level.HasValue) - queryParams.Add($"level={options.Level.Value.ToApiString()}"); - if (options.From.HasValue) - queryParams.Add($"from={Uri.EscapeDataString(options.From.Value.ToString("O"))}"); - if (options.To.HasValue) - queryParams.Add($"to={Uri.EscapeDataString(options.To.Value.ToString("O"))}"); - if (!string.IsNullOrEmpty(options.Query)) - queryParams.Add($"q={Uri.EscapeDataString(options.Query)}"); - queryParams.Add($"limit={options.Limit}"); - queryParams.Add($"offset={options.Offset}"); - - var url = $"/api/v1/logs?{string.Join("&", queryParams)}"; - - using var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new ApiException((int)response.StatusCode, errorBody); - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonSerializer.Deserialize(json, JsonConfig.Options); - - return result ?? new LogsResponse(); - } - - /// - /// Gets logs by trace ID. - /// - public async Task> GetByTraceIdAsync(string traceId, CancellationToken cancellationToken = default) - { - var url = $"/api/v1/logs/trace/{Uri.EscapeDataString(traceId)}"; - - using var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new ApiException((int)response.StatusCode, errorBody); - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonSerializer.Deserialize(json, JsonConfig.Options); - - return result?.Logs ?? new List(); - } - - /// - /// Gets aggregated statistics. - /// - public async Task GetAggregatedStatsAsync( - AggregatedStatsOptions options, - CancellationToken cancellationToken = default) - { - var queryParams = new List - { - $"from={Uri.EscapeDataString(options.From.ToString("O"))}", - $"to={Uri.EscapeDataString(options.To.ToString("O"))}", - $"interval={Uri.EscapeDataString(options.Interval)}" - }; - - if (!string.IsNullOrEmpty(options.Service)) - queryParams.Add($"service={Uri.EscapeDataString(options.Service)}"); - - var url = $"/api/v1/logs/aggregated?{string.Join("&", queryParams)}"; - - using var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new ApiException((int)response.StatusCode, errorBody); - } - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonSerializer.Deserialize(json, JsonConfig.Options); - - return result ?? new AggregatedStatsResponse(); - } - - #endregion - - #region Metrics - - /// - /// Gets the current SDK metrics. - /// - public ClientMetrics GetMetrics() - { - lock (_metricsLock) - { - return _metrics.Clone(); - } - } - - /// - /// Resets the SDK metrics. - /// - public void ResetMetrics() - { - lock (_metricsLock) - { - _metrics = new ClientMetrics(); - _latencyWindow.Clear(); - } - } - - /// - /// Gets the current circuit breaker state. - /// - public CircuitState GetCircuitBreakerState() => _circuitBreaker.State; - - private void UpdateLatency(double latencyMs) - { - lock (_metricsLock) - { - _latencyWindow.Add(latencyMs); - - if (_latencyWindow.Count > 100) - { - _latencyWindow.RemoveAt(0); - } - - if (_latencyWindow.Count > 0) - { - _metrics.AvgLatencyMs = _latencyWindow.Average(); - } - } - } - - #endregion - - #region Helpers - - private static Dictionary SerializeException(Exception ex) - { - var result = new Dictionary - { - ["name"] = ex.GetType().Name, - ["message"] = ex.Message, - ["stack"] = ex.StackTrace - }; - - if (ex.InnerException != null) - { - result["cause"] = SerializeException(ex.InnerException); - } - - return result; - } - - #endregion - - #region Dispose - - /// - /// Disposes the client and flushes remaining logs. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Asynchronously disposes the client and flushes remaining logs. - /// - public async ValueTask DisposeAsync() - { - if (_disposed) return; - - _disposed = true; - await _flushTimer.DisposeAsync(); - await FlushAsync(); - _httpClient.Dispose(); - - if (_options.Debug) - { - Console.WriteLine("[LogTide] Client disposed"); - } - - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) return; - - if (disposing) - { - _disposed = true; - _flushTimer.Dispose(); - FlushAsync().GetAwaiter().GetResult(); - _httpClient.Dispose(); - - if (_options.Debug) - { - Console.WriteLine("[LogTide] Client disposed"); - } - } - } - - #endregion -} diff --git a/Middleware/LogTideErrorHandlerMiddleware.cs b/Middleware/LogTideErrorHandlerMiddleware.cs new file mode 100644 index 0000000..0c6dade --- /dev/null +++ b/Middleware/LogTideErrorHandlerMiddleware.cs @@ -0,0 +1,28 @@ +using LogTide.SDK.Core; +using Microsoft.AspNetCore.Http; + +namespace LogTide.SDK.Middleware; + +public class LogTideErrorHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogTideClient _client; + private readonly string _serviceName; + + public LogTideErrorHandlerMiddleware(RequestDelegate next, ILogTideClient client, string serviceName = "aspnet-api") + { + _next = next; + _client = client; + _serviceName = serviceName; + } + + public async Task InvokeAsync(HttpContext context) + { + try { await _next(context); } + catch (Exception ex) + { + _client.Error(_serviceName, $"Unhandled exception: {ex.Message}", ex); + throw; + } + } +} diff --git a/Middleware/LogTideExtensions.cs b/Middleware/LogTideExtensions.cs index 284377f..c5c4a72 100644 --- a/Middleware/LogTideExtensions.cs +++ b/Middleware/LogTideExtensions.cs @@ -1,98 +1,79 @@ +using LogTide.SDK.Core; +using LogTide.SDK.Models; +using LogTide.SDK.Transport; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using LogTide.SDK.Models; namespace LogTide.SDK.Middleware; -/// -/// Extension methods for adding LogTide to ASP.NET Core applications. -/// public static class LogTideExtensions { /// - /// Adds LogTide client as a singleton service. + /// Adds LogTide client as a singleton service with IHttpClientFactory. /// - /// The service collection. - /// Client configuration options. - /// The service collection. public static IServiceCollection AddLogTide( - this IServiceCollection services, + this IServiceCollection services, ClientOptions options) { - var client = new LogTideClient(options); - services.AddSingleton(client); + services.AddHttpClient("LogTide", client => + { + client.BaseAddress = new Uri(options.ApiUrl.TrimEnd('/')); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", options.ApiKey); + client.Timeout = TimeSpan.FromSeconds(options.HttpTimeoutSeconds); + }); + + services.AddSingleton(sp => + { + var factory = sp.GetRequiredService(); + return new LogTideClient(options, factory); + }); + return services; } /// - /// Adds LogTide client using a factory function. + /// Adds LogTide client using an options configuration action. /// - /// The service collection. - /// Factory function for client options. - /// The service collection. public static IServiceCollection AddLogTide( - this IServiceCollection services, - Func optionsFactory) + this IServiceCollection services, + Action configure) { - services.AddSingleton(sp => - { - var options = optionsFactory(sp); - return new LogTideClient(options); - }); - return services; + var options = new ClientOptions(); + configure(options); + return services.AddLogTide(options); } /// /// Adds LogTide HTTP request/response logging middleware. /// - /// The application builder. - /// Action to configure middleware options. - /// The application builder. public static IApplicationBuilder UseLogTide( this IApplicationBuilder app, - Action optionsAction) + Action? optionsAction = null) { - var options = new LogTideMiddlewareOptions - { - Client = app.ApplicationServices.GetRequiredService() - }; - - optionsAction(options); - + var options = new LogTideMiddlewareOptions(); + optionsAction?.Invoke(options); + return app.UseMiddleware(options); } /// - /// Adds LogTide HTTP request/response logging middleware with the default client. + /// Adds LogTide HTTP request/response logging middleware with a service name. /// - /// The application builder. - /// Service name to use in logs. - /// The application builder. public static IApplicationBuilder UseLogTide( this IApplicationBuilder app, - string serviceName = "aspnet-api") + string serviceName) { - var client = app.ApplicationServices.GetRequiredService(); - - var options = new LogTideMiddlewareOptions - { - Client = client, - ServiceName = serviceName - }; - + var options = new LogTideMiddlewareOptions { ServiceName = serviceName }; return app.UseMiddleware(options); } /// - /// Adds LogTide HTTP request/response logging middleware with explicit options. + /// Adds LogTide error handler middleware that catches unhandled exceptions. /// - /// The application builder. - /// Middleware options. - /// The application builder. - public static IApplicationBuilder UseLogTide( + public static IApplicationBuilder UseLogTideErrors( this IApplicationBuilder app, - LogTideMiddlewareOptions options) + string serviceName = "aspnet-api") { - return app.UseMiddleware(options); + return app.UseMiddleware(serviceName); } } diff --git a/Middleware/LogTideMiddleware.cs b/Middleware/LogTideMiddleware.cs index fd8c07a..b7fa14a 100644 --- a/Middleware/LogTideMiddleware.cs +++ b/Middleware/LogTideMiddleware.cs @@ -1,6 +1,8 @@ using System.Diagnostics; +using LogTide.SDK.Core; using LogTide.SDK.Enums; using LogTide.SDK.Models; +using LogTide.SDK.Tracing; using Microsoft.AspNetCore.Http; namespace LogTide.SDK.Middleware; @@ -10,11 +12,6 @@ namespace LogTide.SDK.Middleware; /// public class LogTideMiddlewareOptions { - /// - /// LogTide client instance. - /// - public LogTideClient? Client { get; set; } - /// /// Service name to use in logs. /// @@ -49,90 +46,90 @@ public class LogTideMiddlewareOptions /// Paths to skip logging for. /// public HashSet SkipPaths { get; set; } = new(); - - /// - /// Header name to read/write trace ID. Default: "X-Trace-Id". - /// - public string TraceIdHeader { get; set; } = "X-Trace-Id"; } /// -/// ASP.NET Core middleware for automatic HTTP request/response logging. +/// ASP.NET Core middleware for automatic HTTP request/response logging with W3C traceparent support. /// public class LogTideMiddleware { + private static readonly HashSet SensitiveHeaders = + new(StringComparer.OrdinalIgnoreCase) + { + "authorization", "cookie", "set-cookie", + "x-api-key", "x-auth-token", "proxy-authorization" + }; + private readonly RequestDelegate _next; private readonly LogTideMiddlewareOptions _options; + private readonly ILogTideClient _client; - /// - /// Creates a new LogTide middleware instance. - /// - public LogTideMiddleware(RequestDelegate next, LogTideMiddlewareOptions options) + public LogTideMiddleware(RequestDelegate next, LogTideMiddlewareOptions options, ILogTideClient client) { _next = next ?? throw new ArgumentNullException(nameof(next)); _options = options ?? throw new ArgumentNullException(nameof(options)); - - if (_options.Client == null) - throw new ArgumentNullException(nameof(options), "LogTideMiddlewareOptions.Client cannot be null"); + _client = client ?? throw new ArgumentNullException(nameof(client)); } - /// - /// Invokes the middleware. - /// public async Task InvokeAsync(HttpContext context) { - // Check if path should be skipped if (ShouldSkip(context)) { await _next(context); return; } - // Get or generate trace ID + // Parse or generate trace ID var traceId = GetOrGenerateTraceId(context); - _options.Client!.SetTraceId(traceId); + using var scope = LogTideScope.Create(traceId); - // Add trace ID to response headers + // Start request span + var span = _client.StartSpan($"{context.Request.Method} {context.Request.Path}"); + var spanStatus = SpanStatus.Ok; + + // Emit traceparent response header context.Response.OnStarting(() => { - context.Response.Headers[_options.TraceIdHeader] = traceId; + context.Response.Headers[W3CTraceContext.HeaderName] = + W3CTraceContext.Create(scope.TraceId, span.SpanId); return Task.CompletedTask; }); var stopwatch = Stopwatch.StartNew(); - // Log request - if (_options.LogRequests) - { - LogRequest(context); - } - try { + if (_options.LogRequests) + LogRequest(context); + await _next(context); stopwatch.Stop(); - // Log response + span.SetAttribute("http.status_code", context.Response.StatusCode); + spanStatus = context.Response.StatusCode >= 500 ? SpanStatus.Error : SpanStatus.Ok; + if (_options.LogResponses) - { LogResponse(context, stopwatch.ElapsedMilliseconds); - } } catch (Exception ex) { stopwatch.Stop(); + spanStatus = SpanStatus.Error; - // Log error - if (_options.LogErrors) + span.AddEvent("exception", new Dictionary { + ["message"] = ex.Message, + ["type"] = ex.GetType().FullName + }); + + if (_options.LogErrors) LogError(context, ex, stopwatch.ElapsedMilliseconds); - } throw; } finally { - _options.Client!.SetTraceId(null); + _client.FinishSpan(span, spanStatus); } } @@ -140,36 +137,33 @@ private bool ShouldSkip(HttpContext context) { var path = context.Request.Path.Value ?? ""; - // Skip health check endpoints if (_options.SkipHealthCheck) { var lowerPath = path.ToLowerInvariant(); if (lowerPath.Contains("/health") || lowerPath == "/ready" || lowerPath == "/live") - { return true; - } - } - - // Skip configured paths - if (_options.SkipPaths.Contains(path)) - { - return true; } - return false; + return _options.SkipPaths.Contains(path); } - private string GetOrGenerateTraceId(HttpContext context) + private static string GetOrGenerateTraceId(HttpContext context) { - // Try to get trace ID from header - if (context.Request.Headers.TryGetValue(_options.TraceIdHeader, out var existingTraceId) - && !string.IsNullOrEmpty(existingTraceId)) + // Try W3C traceparent first + if (context.Request.Headers.TryGetValue(W3CTraceContext.HeaderName, out var traceparent)) + { + var parsed = W3CTraceContext.Parse(traceparent!); + if (parsed.HasValue) return parsed.Value.TraceId; + } + + // Fallback to X-Trace-Id + if (context.Request.Headers.TryGetValue("X-Trace-Id", out var legacyTraceId) + && !string.IsNullOrEmpty(legacyTraceId)) { - return existingTraceId!; + return legacyTraceId!; } - // Generate new trace ID - return Guid.NewGuid().ToString(); + return W3CTraceContext.GenerateTraceId(); } private void LogRequest(HttpContext context) @@ -187,11 +181,11 @@ private void LogRequest(HttpContext context) if (_options.IncludeHeaders) { metadata["headers"] = request.Headers - .Where(h => !h.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + .Where(h => !SensitiveHeaders.Contains(h.Key)) .ToDictionary(h => h.Key, h => h.Value.ToString()); } - _options.Client!.Log(new LogEntry + _client.Log(new LogEntry { Service = _options.ServiceName, Level = LogLevel.Info, @@ -207,44 +201,40 @@ private void LogResponse(HttpContext context, long durationMs) : statusCode >= 400 ? LogLevel.Warn : LogLevel.Info; - var metadata = new Dictionary - { - ["method"] = context.Request.Method, - ["path"] = context.Request.Path.Value, - ["status_code"] = statusCode, - ["duration_ms"] = durationMs - }; - - _options.Client!.Log(new LogEntry + _client.Log(new LogEntry { Service = _options.ServiceName, Level = level, Message = $"{context.Request.Method} {context.Request.Path} {statusCode} ({durationMs}ms)", - Metadata = metadata + Metadata = new Dictionary + { + ["method"] = context.Request.Method, + ["path"] = context.Request.Path.Value, + ["status_code"] = statusCode, + ["duration_ms"] = durationMs + } }); } private void LogError(HttpContext context, Exception exception, long durationMs) { - var metadata = new Dictionary - { - ["method"] = context.Request.Method, - ["path"] = context.Request.Path.Value, - ["duration_ms"] = durationMs, - ["error"] = new Dictionary - { - ["name"] = exception.GetType().Name, - ["message"] = exception.Message, - ["stack"] = exception.StackTrace - } - }; - - _options.Client!.Log(new LogEntry + _client.Log(new LogEntry { Service = _options.ServiceName, Level = LogLevel.Error, Message = $"Request error: {exception.Message}", - Metadata = metadata + Metadata = new Dictionary + { + ["method"] = context.Request.Method, + ["path"] = context.Request.Path.Value, + ["duration_ms"] = durationMs, + ["error"] = new Dictionary + { + ["name"] = exception.GetType().Name, + ["message"] = exception.Message, + ["stack"] = exception.StackTrace + } + } }); } } diff --git a/Models/ClientOptions.cs b/Models/ClientOptions.cs index 997edb2..2575a77 100644 --- a/Models/ClientOptions.cs +++ b/Models/ClientOptions.cs @@ -1,3 +1,5 @@ +using LogTide.SDK.Integrations; + namespace LogTide.SDK.Models; /// @@ -6,7 +8,7 @@ namespace LogTide.SDK.Models; public class ClientOptions { /// - /// Base URL of the LogTide API (e.g., "https://logward.dev" or "http://localhost:8080"). + /// Base URL of the LogTide API (e.g., "https://logtide.dev" or "http://localhost:8080"). /// public string ApiUrl { get; set; } = string.Empty; @@ -15,6 +17,26 @@ public class ClientOptions /// public string ApiKey { get; set; } = string.Empty; + /// + /// DSN string that encodes both API URL and API key (e.g., "https://lp_mykey@api.logtide.dev"). + /// + public string? Dsn { get; set; } + + /// + /// Service name for tracing. Default: "app". + /// + public string ServiceName { get; set; } = "app"; + + /// + /// Sample rate for traces (0.0 to 1.0). Default: 1.0. + /// + public double TracesSampleRate { get; set; } = 1.0; + + /// + /// Integrations to register on client initialization. + /// + public List Integrations { get; set; } = []; + /// /// Number of logs to batch before sending. Default: 100. /// @@ -74,4 +96,28 @@ public class ClientOptions /// HTTP timeout in seconds. Default: 30. /// public int HttpTimeoutSeconds { get; set; } = 30; + + /// + /// Creates ClientOptions from a DSN string. + /// + public static ClientOptions FromDsn(string dsn) + { + var uri = new Uri(dsn); + return new ClientOptions + { + ApiUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}", + ApiKey = uri.UserInfo + }; + } + + /// + /// Resolves DSN into ApiUrl and ApiKey if they are not already set. + /// + internal void Resolve() + { + if (string.IsNullOrEmpty(Dsn)) return; + var parsed = FromDsn(Dsn); + if (string.IsNullOrEmpty(ApiUrl)) ApiUrl = parsed.ApiUrl; + if (string.IsNullOrEmpty(ApiKey)) ApiKey = parsed.ApiKey; + } } diff --git a/Models/LogEntry.cs b/Models/LogEntry.cs index 532190e..aca7e84 100644 --- a/Models/LogEntry.cs +++ b/Models/LogEntry.cs @@ -43,6 +43,18 @@ public class LogEntry /// [JsonPropertyName("trace_id")] public string? TraceId { get; set; } + + /// + /// Optional span ID for distributed tracing. + /// + [JsonPropertyName("span_id")] + public string? SpanId { get; set; } + + /// + /// Optional session ID for user session tracking. + /// + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } } /// @@ -68,6 +80,12 @@ internal class SerializableLogEntry [JsonPropertyName("trace_id")] public string? TraceId { get; set; } + [JsonPropertyName("span_id")] + public string? SpanId { get; set; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + public static SerializableLogEntry FromLogEntry(LogEntry entry) => new() { Service = entry.Service, @@ -75,6 +93,8 @@ internal class SerializableLogEntry Message = entry.Message, Time = entry.Time, Metadata = entry.Metadata.Count > 0 ? entry.Metadata : null, - TraceId = entry.TraceId + TraceId = entry.TraceId, + SpanId = entry.SpanId, + SessionId = entry.SessionId }; } diff --git a/README.md b/README.md index c606c14..c549503 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@

NuGet License - .NET + .NET Release

- Official .NET SDK for LogTide with automatic batching, retry logic, circuit breaker, query API, distributed tracing, and ASP.NET Core middleware support. + Official .NET SDK for LogTide with automatic batching, retry logic, circuit breaker, W3C distributed tracing, span tracking, breadcrumbs, and ASP.NET Core middleware support.

--- @@ -22,20 +22,23 @@ - **Automatic batching** with configurable size and interval - **Retry logic** with exponential backoff - **Circuit breaker** pattern for fault tolerance -- **Max buffer size** with drop policy to prevent memory leaks +- **W3C traceparent** distributed tracing +- **Span tracking** with OpenTelemetry-compatible export +- **AsyncLocal scope** for ambient trace context +- **Breadcrumbs** for event tracking within a scope +- **Composable transport layer** (LogTide HTTP, OTLP) +- **Integration system** (global error handler, extensible) +- **Serilog sink** (`LogTide.SDK.Serilog`) +- **DSN connection string** support +- **ASP.NET Core middleware** with sensitive header filtering - **Query API** for searching and filtering logs -- **Trace ID context** for distributed tracing -- **Global metadata** added to all logs -- **Structured error serialization** -- **Internal metrics** (logs sent, errors, latency, etc.) -- **ASP.NET Core middleware** for auto-logging HTTP requests -- **Dependency injection support** +- **Dependency injection** with `IHttpClientFactory` - **Full async/await support** - **Thread-safe** ## Requirements -- .NET 6.0, 7.0, or 8.0 +- .NET 8.0 or .NET 9.0 ## Installation @@ -43,407 +46,158 @@ dotnet add package LogTide.SDK ``` -Or via Package Manager: +For Serilog integration: -```powershell -Install-Package LogTide.SDK +```bash +dotnet add package LogTide.SDK.Serilog ``` ## Quick Start ```csharp -using LogTide.SDK; +using LogTide.SDK.Core; using LogTide.SDK.Models; -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "http://localhost:8080", - ApiKey = "lp_your_api_key_here" -}); +// Create client with DSN +await using var client = LogTideClient.FromDsn("https://lp_your_key@api.logtide.dev"); // Send logs client.Info("api-gateway", "Server started", new() { ["port"] = 3000 }); client.Error("database", "Connection failed", new Exception("Timeout")); -// Graceful shutdown -await client.DisposeAsync(); -``` - ---- - -## Configuration Options - -### Basic Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `ApiUrl` | `string` | **required** | Base URL of your LogTide instance | -| `ApiKey` | `string` | **required** | Project API key (starts with `lp_`) | -| `BatchSize` | `int` | `100` | Number of logs to batch before sending | -| `FlushIntervalMs` | `int` | `5000` | Interval in ms to auto-flush logs | - -### Advanced Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `MaxBufferSize` | `int` | `10000` | Max logs in buffer (prevents memory leak) | -| `MaxRetries` | `int` | `3` | Max retry attempts on failure | -| `RetryDelayMs` | `int` | `1000` | Initial retry delay (exponential backoff) | -| `CircuitBreakerThreshold` | `int` | `5` | Failures before opening circuit | -| `CircuitBreakerResetMs` | `int` | `30000` | Time before retrying after circuit opens | -| `EnableMetrics` | `bool` | `true` | Track internal metrics | -| `Debug` | `bool` | `false` | Enable debug logging to console | -| `GlobalMetadata` | `Dictionary` | `{}` | Metadata added to all logs | -| `AutoTraceId` | `bool` | `false` | Auto-generate trace IDs for logs | -| `HttpTimeoutSeconds` | `int` | `30` | HTTP request timeout | - -### Example: Full Configuration - -```csharp -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "http://localhost:8080", - ApiKey = "lp_your_api_key_here", - - // Batching - BatchSize = 100, - FlushIntervalMs = 5000, - - // Buffer management - MaxBufferSize = 10000, - - // Retry with exponential backoff (1s -> 2s -> 4s) - MaxRetries = 3, - RetryDelayMs = 1000, - - // Circuit breaker - CircuitBreakerThreshold = 5, - CircuitBreakerResetMs = 30000, - - // Metrics & debugging - EnableMetrics = true, - Debug = true, - - // Global context - GlobalMetadata = new() - { - ["env"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), - ["version"] = "1.0.0", - ["hostname"] = Environment.MachineName - }, - - // Auto trace IDs - AutoTraceId = false -}); -``` - ---- - -## Logging Methods - -### Basic Logging - -```csharp -client.Debug("service-name", "Debug message"); -client.Info("service-name", "Info message", new() { ["userId"] = 123 }); -client.Warn("service-name", "Warning message"); -client.Error("service-name", "Error message", new() { ["custom"] = "data" }); -client.Critical("service-name", "Critical message"); -``` - -### Error Logging with Auto-Serialization - -The SDK automatically serializes exceptions: - -```csharp -try -{ - throw new InvalidOperationException("Database timeout"); -} -catch (Exception ex) +// Use scoped tracing +using (var scope = LogTideScope.Create()) { - // Automatically serializes error with stack trace - client.Error("database", "Query failed", ex); -} -``` - -Generated log metadata: -```json -{ - "error": { - "name": "InvalidOperationException", - "message": "Database timeout", - "stack": "at Program.Main() in ..." - } + client.Info("api", "Request received"); // automatically gets W3C trace ID + client.Info("db", "Query executed"); // same trace ID } ``` --- -## Trace ID Context - -Track requests across services with trace IDs. - -### Manual Trace ID - -```csharp -client.SetTraceId("request-123"); - -client.Info("api", "Request received"); -client.Info("database", "Querying users"); -client.Info("api", "Response sent"); - -client.SetTraceId(null); // Clear context -``` - -### Scoped Trace ID +## ASP.NET Core Integration ```csharp -client.WithTraceId("request-456", () => -{ - client.Info("api", "Processing in context"); - client.Warn("cache", "Cache miss"); -}); -// Trace ID automatically restored after block -``` +using LogTide.SDK.Core; +using LogTide.SDK.Middleware; +using LogTide.SDK.Models; -### Auto-Generated Trace ID +var builder = WebApplication.CreateBuilder(args); -```csharp -client.WithNewTraceId(() => +// Register LogTide with IHttpClientFactory +builder.Services.AddLogTide(new ClientOptions { - client.Info("worker", "Background job started"); - client.Info("worker", "Job completed"); + ApiUrl = builder.Configuration["LogTide:ApiUrl"]!, + ApiKey = builder.Configuration["LogTide:ApiKey"]!, + ServiceName = "my-api", + GlobalMetadata = new() { ["env"] = builder.Environment.EnvironmentName } }); -``` - ---- -## Query API +var app = builder.Build(); -Search and retrieve logs programmatically. +// Catch unhandled exceptions +app.UseLogTideErrors(); -### Basic Query +// Auto-log HTTP requests with W3C traceparent support +app.UseLogTide(o => o.ServiceName = "my-api"); -```csharp -var result = await client.QueryAsync(new QueryOptions +app.MapGet("/", (ILogTideClient logger) => { - Service = "api-gateway", - Level = LogLevel.Error, - From = DateTime.UtcNow.AddDays(-1), - To = DateTime.UtcNow, - Limit = 100, - Offset = 0 + logger.Info("my-api", "Hello!"); + return Results.Ok(); }); -Console.WriteLine($"Found {result.Total} logs"); -foreach (var log in result.Logs) -{ - Console.WriteLine($"{log.Time}: {log.Message}"); -} +app.Run(); ``` -### Full-Text Search +The middleware automatically: +- Parses incoming `traceparent` headers (W3C standard) +- Creates a `LogTideScope` per request +- Starts and finishes a span per request +- Emits `traceparent` response header +- Filters sensitive headers (`Authorization`, `Cookie`, etc.) -```csharp -var result = await client.QueryAsync(new QueryOptions -{ - Query = "timeout", - Limit = 50 -}); -``` +--- -### Get Logs by Trace ID +## Span Tracking ```csharp -var logs = await client.GetByTraceIdAsync("trace-123"); -Console.WriteLine($"Trace has {logs.Count} logs"); -``` +using var scope = LogTideScope.Create(); -### Aggregated Statistics +var span = client.StartSpan("process-order"); +span.SetAttribute("order.id", "ORD-123"); -```csharp -var stats = await client.GetAggregatedStatsAsync(new AggregatedStatsOptions -{ - From = DateTime.UtcNow.AddDays(-7), - To = DateTime.UtcNow, - Interval = "1h", // "1m" | "5m" | "1h" | "1d" - Service = "api-gateway" // Optional -}); +// ... do work ... -Console.WriteLine("Time series:"); -foreach (var entry in stats.Timeseries) -{ - Console.WriteLine($" {entry.Bucket}: {entry.Total} logs"); -} +span.AddEvent("validation-complete"); +client.FinishSpan(span, SpanStatus.Ok); ``` ---- +Spans are exported in OTLP format to `/v1/otlp/traces`. -## Metrics +--- -Track SDK performance and health. +## Breadcrumbs ```csharp -var metrics = client.GetMetrics(); - -Console.WriteLine($"Logs sent: {metrics.LogsSent}"); -Console.WriteLine($"Logs dropped: {metrics.LogsDropped}"); -Console.WriteLine($"Errors: {metrics.Errors}"); -Console.WriteLine($"Retries: {metrics.Retries}"); -Console.WriteLine($"Avg latency: {metrics.AvgLatencyMs}ms"); -Console.WriteLine($"Circuit breaker trips: {metrics.CircuitBreakerTrips}"); +using var scope = LogTideScope.Create(); -// Get circuit breaker state -Console.WriteLine($"Circuit state: {client.GetCircuitBreakerState()}"); // Closed | Open | HalfOpen +client.AddBreadcrumb(new Breadcrumb { Message = "User clicked button", Type = "ui" }); +client.AddBreadcrumb(new Breadcrumb { Message = "API call started", Type = "http" }); -// Reset metrics -client.ResetMetrics(); +// Breadcrumbs are automatically attached to logs within this scope +client.Error("app", "Something failed"); ``` --- -## ASP.NET Core Integration - -### Setup with Dependency Injection - -**Program.cs:** +## Serilog Integration ```csharp -using LogTide.SDK; -using LogTide.SDK.Middleware; -using LogTide.SDK.Models; +using LogTide.SDK.Serilog; -var builder = WebApplication.CreateBuilder(args); +await using var logtideClient = LogTideClient.FromDsn("https://lp_key@api.logtide.dev"); -// Add LogTide -builder.Services.AddLogTide(new ClientOptions -{ - ApiUrl = builder.Configuration["LogTide:ApiUrl"]!, - ApiKey = builder.Configuration["LogTide:ApiKey"]!, - GlobalMetadata = new() - { - ["env"] = builder.Environment.EnvironmentName - } -}); - -var app = builder.Build(); - -// Add middleware for auto-logging HTTP requests -app.UseLogTide(options => -{ - options.ServiceName = "my-api"; - options.LogRequests = true; - options.LogResponses = true; - options.LogErrors = true; - options.SkipHealthCheck = true; - options.SkipPaths.Add("/metrics"); -}); - -app.MapGet("/", () => "Hello World!"); +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.LogTide(logtideClient, serviceName: "my-service") + .CreateLogger(); -app.Run(); +Log.Information("User {UserId} logged in", 42); // forwarded to LogTide ``` -### Middleware Options +--- + +## Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| -| `ServiceName` | `string` | `"aspnet-api"` | Service name in logs | -| `LogRequests` | `bool` | `true` | Log incoming requests | -| `LogResponses` | `bool` | `true` | Log outgoing responses | -| `LogErrors` | `bool` | `true` | Log unhandled exceptions | -| `IncludeHeaders` | `bool` | `false` | Include request headers | -| `SkipHealthCheck` | `bool` | `true` | Skip /health endpoints | -| `SkipPaths` | `HashSet` | `{}` | Paths to skip | -| `TraceIdHeader` | `string` | `"X-Trace-Id"` | Header for trace ID | - -### Using LogTide in Controllers - -```csharp -[ApiController] -[Route("[controller]")] -public class WeatherController : ControllerBase -{ - private readonly LogTideClient _logger; - - public WeatherController(LogTideClient logger) - { - _logger = logger; - } - - [HttpGet] - public IActionResult Get() - { - _logger.Info("weather-api", "Fetching weather data"); - - try - { - // ... business logic - return Ok(new { Temperature = 25 }); - } - catch (Exception ex) - { - _logger.Error("weather-api", "Failed to fetch weather", ex); - throw; - } - } -} -``` +| `ApiUrl` | `string` | **required** | Base URL of your LogTide instance | +| `ApiKey` | `string` | **required** | Project API key (starts with `lp_`) | +| `Dsn` | `string?` | `null` | DSN string (alternative to ApiUrl + ApiKey) | +| `ServiceName` | `string` | `"app"` | Service name for tracing | +| `BatchSize` | `int` | `100` | Logs to batch before sending | +| `FlushIntervalMs` | `int` | `5000` | Auto-flush interval in ms | +| `MaxBufferSize` | `int` | `10000` | Max buffer size (drop policy) | +| `MaxRetries` | `int` | `3` | Retry attempts on failure | +| `RetryDelayMs` | `int` | `1000` | Initial retry delay (exponential backoff) | +| `CircuitBreakerThreshold` | `int` | `5` | Failures before opening circuit | +| `CircuitBreakerResetMs` | `int` | `30000` | Time before retrying after circuit opens | +| `TracesSampleRate` | `double` | `1.0` | Sample rate for traces | +| `Integrations` | `List` | `[]` | Integrations to register | +| `GlobalMetadata` | `Dictionary` | `{}` | Metadata added to all logs | +| `Debug` | `bool` | `false` | Enable debug logging to console | --- -## Best Practices +## Breaking Changes (v0.8.3) -### 1. Always Dispose on Shutdown - -```csharp -var client = new LogTideClient(options); - -// ... use client - -// Dispose flushes remaining logs -await client.DisposeAsync(); -``` - -Or with ASP.NET Core: - -```csharp -var app = builder.Build(); - -app.Lifetime.ApplicationStopping.Register(async () => -{ - var logger = app.Services.GetRequiredService(); - await logger.FlushAsync(); -}); -``` - -### 2. Use Global Metadata - -```csharp -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "...", - ApiKey = "...", - GlobalMetadata = new() - { - ["env"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), - ["version"] = typeof(Program).Assembly.GetName().Version?.ToString(), - ["machine"] = Environment.MachineName - } -}); -``` - -### 3. Enable Debug Mode in Development - -```csharp -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "...", - ApiKey = "...", - Debug = builder.Environment.IsDevelopment() -}); -``` +- `SetTraceId()`, `GetTraceId()`, `WithTraceId()`, `WithNewTraceId()` removed — use `LogTideScope.Create(traceId)` +- `LogTideMiddlewareOptions.Client` removed — client resolved from DI +- Default trace header: `X-Trace-Id` replaced by W3C `traceparent` +- Target frameworks: `net8.0;net9.0` (dropped net6/net7) +- `LogTideClient` is now `sealed`, implements `ILogTideClient` +- `LogEntry` has new optional fields `SpanId`, `SessionId` --- @@ -451,8 +205,6 @@ var client = new LogTideClient(new ClientOptions See the [examples/](./examples) directory for complete working examples. ---- - ## Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. diff --git a/Serilog/LogTide.SDK.Serilog.csproj b/Serilog/LogTide.SDK.Serilog.csproj new file mode 100644 index 0000000..513b0ff --- /dev/null +++ b/Serilog/LogTide.SDK.Serilog.csproj @@ -0,0 +1,15 @@ + + + net8.0;net9.0 + enable + enable + 13 + 0.8.3 + LogTide.SDK.Serilog + true + + + + + + diff --git a/Serilog/LogTideSink.cs b/Serilog/LogTideSink.cs new file mode 100644 index 0000000..46399f4 --- /dev/null +++ b/Serilog/LogTideSink.cs @@ -0,0 +1,64 @@ +using Serilog.Core; +using Serilog.Events; +using LogTide.SDK.Core; +using LogTide.SDK.Enums; +using LogTide.SDK.Models; + +namespace LogTide.SDK.Serilog; + +public sealed class LogTideSink : ILogEventSink +{ + private readonly ILogTideClient _client; + private readonly string _serviceName; + + public LogTideSink(ILogTideClient client, string serviceName = "app") + { + _client = client; + _serviceName = serviceName; + } + + public void Emit(LogEvent logEvent) + { + var metadata = new Dictionary(); + foreach (var p in logEvent.Properties) + metadata[p.Key] = ExtractValue(p.Value); + if (logEvent.Exception != null) + metadata["error"] = SerializeException(logEvent.Exception); + + _client.Log(new LogEntry + { + Service = _serviceName, + Level = MapLevel(logEvent.Level), + Message = logEvent.RenderMessage(), + Metadata = metadata + }); + } + + private static LogLevel MapLevel(LogEventLevel level) => level switch + { + LogEventLevel.Verbose or LogEventLevel.Debug => LogLevel.Debug, + LogEventLevel.Information => LogLevel.Info, + LogEventLevel.Warning => LogLevel.Warn, + LogEventLevel.Error => LogLevel.Error, + LogEventLevel.Fatal => LogLevel.Critical, + _ => LogLevel.Info + }; + + private static object? ExtractValue(LogEventPropertyValue value) => value switch + { + ScalarValue sv => sv.Value, + SequenceValue seq => seq.Elements.Select(ExtractValue).ToArray(), + StructureValue struc => struc.Properties.ToDictionary(p => p.Name, p => ExtractValue(p.Value) as object), + DictionaryValue dict => dict.Elements.ToDictionary( + kv => ExtractValue(kv.Key)?.ToString() ?? string.Empty, + kv => ExtractValue(kv.Value)), + _ => value.ToString() + }; + + private static Dictionary SerializeException(Exception ex) + { + var r = new Dictionary { ["type"] = ex.GetType().FullName, ["message"] = ex.Message, ["stack"] = ex.StackTrace }; + if (ex.InnerException != null) r["cause"] = SerializeException(ex.InnerException); + return r; + } +} diff --git a/Serilog/LogTideSinkExtensions.cs b/Serilog/LogTideSinkExtensions.cs new file mode 100644 index 0000000..9915f91 --- /dev/null +++ b/Serilog/LogTideSinkExtensions.cs @@ -0,0 +1,16 @@ +using Serilog; +using Serilog.Configuration; +using Serilog.Events; +using LogTide.SDK.Core; + +namespace LogTide.SDK.Serilog; + +public static class LogTideSinkExtensions +{ + public static LoggerConfiguration LogTide( + this LoggerSinkConfiguration sinkConfiguration, + ILogTideClient client, + string serviceName = "app", + LogEventLevel minimumLevel = LogEventLevel.Verbose) => + sinkConfiguration.Sink(new LogTideSink(client, serviceName), minimumLevel); +} diff --git a/Tracing/Span.cs b/Tracing/Span.cs new file mode 100644 index 0000000..e06a874 --- /dev/null +++ b/Tracing/Span.cs @@ -0,0 +1,51 @@ +namespace LogTide.SDK.Tracing; + +public sealed class Span +{ + public string SpanId { get; } + public string TraceId { get; } + public string? ParentSpanId { get; } + public string Name { get; set; } + public DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EndTime { get; private set; } + public SpanStatus Status { get; private set; } = SpanStatus.Unset; + public Dictionary Attributes { get; } = new(); + public List Events { get; } = new(); + public bool IsFinished => EndTime.HasValue; + + public Span(string spanId, string traceId, string? parentSpanId, string name) + { + SpanId = spanId; + TraceId = traceId; + ParentSpanId = parentSpanId; + Name = name; + } + + public void SetStatus(SpanStatus status) => Status = status; + public void SetAttribute(string key, object? value) => Attributes[key] = value; + + public void AddEvent(string name, Dictionary? attrs = null) + => Events.Add(new SpanEvent(name, DateTimeOffset.UtcNow, attrs ?? new())); + + public void Finish(SpanStatus status = SpanStatus.Ok) + { + Status = status; + EndTime = DateTimeOffset.UtcNow; + } +} + +public sealed class SpanEvent +{ + public string Name { get; } + public DateTimeOffset Timestamp { get; } + public Dictionary Attributes { get; } + + public SpanEvent(string name, DateTimeOffset ts, Dictionary attrs) + { + Name = name; + Timestamp = ts; + Attributes = attrs; + } +} + +public enum SpanStatus { Unset, Ok, Error } diff --git a/Tracing/SpanManager.cs b/Tracing/SpanManager.cs new file mode 100644 index 0000000..a02dcc2 --- /dev/null +++ b/Tracing/SpanManager.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; + +namespace LogTide.SDK.Tracing; + +internal sealed class SpanManager +{ + private readonly ConcurrentDictionary _spans = new(); + + public Span StartSpan(string name, string traceId, string? parentSpanId = null) + { + var span = new Span(W3CTraceContext.GenerateSpanId(), traceId, parentSpanId, name); + _spans.TryAdd(span.SpanId, span); + return span; + } + + public bool TryFinishSpan(string spanId, SpanStatus status, out Span? span) + { + if (_spans.TryRemove(spanId, out span)) + { + span.Finish(status); + return true; + } + return false; + } +} diff --git a/Tracing/W3CTraceContext.cs b/Tracing/W3CTraceContext.cs new file mode 100644 index 0000000..c9db91b --- /dev/null +++ b/Tracing/W3CTraceContext.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; + +namespace LogTide.SDK.Tracing; + +public static class W3CTraceContext +{ + public const string HeaderName = "traceparent"; + + public static (string TraceId, string SpanId)? Parse(string? traceparent) + { + if (string.IsNullOrEmpty(traceparent)) return null; + var parts = traceparent.Split('-'); + if (parts.Length != 4 || parts[0] != "00") return null; + if (parts[1].Length != 32 || parts[2].Length != 16) return null; + if (!IsLowercaseHex(parts[1]) || !IsLowercaseHex(parts[2])) return null; + if (parts[3].Length != 2 || !IsLowercaseHex(parts[3])) return null; + // W3C spec: all-zeros traceId and parentId are invalid + if (parts[1] == "00000000000000000000000000000000") return null; + if (parts[2] == "0000000000000000") return null; + return (parts[1], parts[2]); + } + + private static bool IsLowercaseHex(string s) + { + foreach (var c in s) + { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false; + } + return true; + } + + public static string Create(string traceId, string spanId, bool sampled = true) => + $"00-{traceId}-{spanId}-{(sampled ? "01" : "00")}"; + + public static string GenerateTraceId() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + + public static string GenerateSpanId() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); +} diff --git a/Transport/BatchTransport.cs b/Transport/BatchTransport.cs new file mode 100644 index 0000000..72431b7 --- /dev/null +++ b/Transport/BatchTransport.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using LogTide.SDK.Enums; +using LogTide.SDK.Exceptions; +using LogTide.SDK.Internal; +using LogTide.SDK.Models; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Transport; + +internal sealed class BatchTransport : IDisposable, IAsyncDisposable +{ + private readonly ILogTransport _logTransport; + private readonly ISpanTransport? _spanTransport; + private readonly ClientOptions _options; + private readonly CircuitBreaker _circuitBreaker; + private readonly List _logBuffer = new(); + private readonly List _spanBuffer = new(); + private readonly object _lock = new(); + private readonly object _metricsLock = new(); + private readonly Timer _flushTimer; + private readonly List _latencyWindow = new(); + private ClientMetrics _metrics = new(); + private int _disposed; // 0 = not disposed, 1 = disposed; accessed via Interlocked + + public BatchTransport(ILogTransport logTransport, ISpanTransport? spanTransport, ClientOptions options) + { + _logTransport = logTransport; + _spanTransport = spanTransport; + _options = options; + _circuitBreaker = new CircuitBreaker(options.CircuitBreakerThreshold, options.CircuitBreakerResetMs); + _flushTimer = new Timer(_ => FireAndForgetFlush(), null, options.FlushIntervalMs, options.FlushIntervalMs); + } + + public void Enqueue(LogEntry entry) + { + bool shouldFlush; + lock (_lock) + { + if (_logBuffer.Count >= _options.MaxBufferSize) + { + lock (_metricsLock) { _metrics.LogsDropped++; } + throw new BufferFullException(); + } + _logBuffer.Add(entry); + shouldFlush = _logBuffer.Count >= _options.BatchSize; + } + if (shouldFlush) FireAndForgetFlush(); + } + + public void EnqueueSpan(Span span) + { + lock (_lock) { _spanBuffer.Add(span); } + } + + private void FireAndForgetFlush(object? _ = null) + { + if (Volatile.Read(ref _disposed) == 1) return; + Task.Run(() => FlushAsync()).ContinueWith(t => + { + if (t.IsFaulted && _options.Debug) + Console.WriteLine($"[LogTide] Flush error: {t.Exception?.GetBaseException().Message}"); + }, TaskContinuationOptions.OnlyOnFaulted); + } + + public async Task FlushAsync(CancellationToken ct = default) + { + List logs; + List spans; + lock (_lock) + { + if (_logBuffer.Count == 0 && _spanBuffer.Count == 0) return; + logs = new List(_logBuffer); + spans = new List(_spanBuffer); + _logBuffer.Clear(); + _spanBuffer.Clear(); + } + + if (logs.Count > 0) await SendWithRetryAsync(logs, ct).ConfigureAwait(false); + if (spans.Count > 0 && _spanTransport != null) + await SendSpansWithRetryAsync(spans, ct).ConfigureAwait(false); + } + + private async Task SendWithRetryAsync(List logs, CancellationToken ct) + { + var attempt = 0; + var delay = _options.RetryDelayMs; + while (attempt <= _options.MaxRetries) + { + try + { + if (!_circuitBreaker.CanAttempt()) + { + lock (_metricsLock) { _metrics.CircuitBreakerTrips++; } + break; // drop to the single LogsDropped increment below + } + var sw = Stopwatch.StartNew(); + await _logTransport.SendAsync(logs, ct).ConfigureAwait(false); + sw.Stop(); + _circuitBreaker.RecordSuccess(); + UpdateLatency(sw.Elapsed.TotalMilliseconds); + lock (_metricsLock) { _metrics.LogsSent += logs.Count; } + return; + } + catch (Exception) + { + attempt++; + lock (_metricsLock) { _metrics.Errors++; if (attempt <= _options.MaxRetries) _metrics.Retries++; } + if (attempt > _options.MaxRetries) + { + _circuitBreaker.RecordFailure(); // record once after all retries exhausted + break; + } + await Task.Delay(delay, ct).ConfigureAwait(false); + delay *= 2; + } + } + lock (_metricsLock) { _metrics.LogsDropped += logs.Count; } + } + + private async Task SendSpansWithRetryAsync(List spans, CancellationToken ct) + { + try { await _spanTransport!.SendSpansAsync(spans, ct).ConfigureAwait(false); } + catch (Exception ex) + { + if (_options.Debug) + Console.WriteLine($"[LogTide] Span send error: {ex.Message}"); + } + } + + private void UpdateLatency(double ms) + { + lock (_metricsLock) + { + _latencyWindow.Add(ms); + if (_latencyWindow.Count > 100) _latencyWindow.RemoveAt(0); + _metrics.AvgLatencyMs = _latencyWindow.Average(); + } + } + + public ClientMetrics GetMetrics() { lock (_metricsLock) { return _metrics.Clone(); } } + public void ResetMetrics() { lock (_metricsLock) { _metrics = new(); _latencyWindow.Clear(); } } + public CircuitState CircuitBreakerState => _circuitBreaker.State; + + public void Dispose() + { + // Task.Run avoids deadlock when caller has a SynchronizationContext + Task.Run(() => DisposeAsync().AsTask()).GetAwaiter().GetResult(); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + await _flushTimer.DisposeAsync().ConfigureAwait(false); + await FlushAsync().ConfigureAwait(false); + } +} diff --git a/Transport/ITransport.cs b/Transport/ITransport.cs new file mode 100644 index 0000000..c2dea93 --- /dev/null +++ b/Transport/ITransport.cs @@ -0,0 +1,14 @@ +using LogTide.SDK.Models; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Transport; + +internal interface ILogTransport +{ + Task SendAsync(IReadOnlyList logs, CancellationToken ct = default); +} + +internal interface ISpanTransport +{ + Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default); +} diff --git a/Transport/LogTideHttpTransport.cs b/Transport/LogTideHttpTransport.cs new file mode 100644 index 0000000..fa0a960 --- /dev/null +++ b/Transport/LogTideHttpTransport.cs @@ -0,0 +1,25 @@ +using System.Text; +using System.Text.Json; +using LogTide.SDK.Exceptions; +using LogTide.SDK.Internal; +using LogTide.SDK.Models; + +namespace LogTide.SDK.Transport; + +internal sealed class LogTideHttpTransport : ILogTransport +{ + private readonly HttpClient _httpClient; + + public LogTideHttpTransport(HttpClient httpClient) => _httpClient = httpClient; + + public async Task SendAsync(IReadOnlyList logs, CancellationToken ct = default) + { + var payload = new { logs = logs.Select(SerializableLogEntry.FromLogEntry) }; + var json = JsonSerializer.Serialize(payload, JsonConfig.Options); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync("/api/v1/ingest", content, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new ApiException((int)response.StatusCode, + await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)); + } +} diff --git a/Transport/OtlpHttpTransport.cs b/Transport/OtlpHttpTransport.cs new file mode 100644 index 0000000..750513b --- /dev/null +++ b/Transport/OtlpHttpTransport.cs @@ -0,0 +1,61 @@ +using System.Text; +using System.Text.Json; +using LogTide.SDK.Exceptions; +using LogTide.SDK.Internal; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Transport; + +internal sealed class OtlpHttpTransport : ISpanTransport +{ + private readonly HttpClient _httpClient; + private readonly string _serviceName; + + public OtlpHttpTransport(HttpClient httpClient, string serviceName) + { + _httpClient = httpClient; + _serviceName = serviceName; + } + + public async Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default) + { + var payload = BuildOtlpPayload(spans); + var json = JsonSerializer.Serialize(payload, JsonConfig.Options); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync("/v1/otlp/traces", content, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new ApiException((int)response.StatusCode, + await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false)); + } + + private object BuildOtlpPayload(IReadOnlyList spans) => new + { + resourceSpans = new[] + { + new + { + resource = new + { + attributes = new[] { new { key = "service.name", value = new { stringValue = _serviceName } } } + }, + scopeSpans = new[] + { + new { spans = spans.Select(ToOtlp).ToArray() } + } + } + } + }; + + private static object ToOtlp(Span s) => new + { + traceId = s.TraceId, + spanId = s.SpanId, + parentSpanId = s.ParentSpanId, + name = s.Name, + startTimeUnixNano = s.StartTime.ToUnixTimeMilliseconds() * 1_000_000L, + endTimeUnixNano = (s.EndTime ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() * 1_000_000L, + status = new { code = (int)s.Status }, + attributes = s.Attributes.Select(kv => new { key = kv.Key, value = new { stringValue = kv.Value?.ToString() } }).ToArray(), + events = s.Events.Select(e => new { name = e.Name, timeUnixNano = e.Timestamp.ToUnixTimeMilliseconds() * 1_000_000L }).ToArray() + }; +} diff --git a/docs/superpowers/plans/2026-03-23-logtide-dotnet-refactor.md b/docs/superpowers/plans/2026-03-23-logtide-dotnet-refactor.md new file mode 100644 index 0000000..69aefc1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-logtide-dotnet-refactor.md @@ -0,0 +1,1740 @@ +# LogTide .NET SDK — Full Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete refactor of LogTide .NET SDK: fix bugs + vulnerabilities, adopt W3C traceparent, AsyncLocal scope, composable transport, span tracking, integrations plugin system, Serilog sink, comprehensive tests. + +**Architecture:** Single solution with two projects (`LogTide.SDK` + `LogTide.SDK.Serilog`). SDK reorganized into `Core/`, `Transport/`, `Tracing/`, `Integrations/`, `Breadcrumbs/` subfolders. `BatchTransport` owns buffering/retry/circuit-breaker; `LogTideClient` is a thin façade that enriches from `AsyncLocal` and delegates. + +**Tech Stack:** .NET 8+9, xUnit 2.x, NSubstitute (replaces Moq), Serilog 4.x + +--- + +## File Map + +### Modified +- `LogTide.SDK.csproj` — TFMs net8+net9, remove vulnerable packages, update deps +- `LogTide.SDK.sln` — add Serilog project + test project +- `Models/LogEntry.cs` — add `SpanId`, `SessionId` +- `Models/ClientOptions.cs` — add `Dsn`, `ServiceName`, `TracesSampleRate`, `Integrations` +- `Internal/CircuitBreaker.cs` — fix HalfOpen probe (one probe at a time) +- `Middleware/LogTideMiddleware.cs` — W3C traceparent, scope, spans, sensitive header strip +- `Middleware/LogTideExtensions.cs` — IHttpClientFactory, new UseLogTideErrors() +- `tests/LogTide.SDK.Tests.csproj` — remove vulnerable packages, add NSubstitute +- `examples/*.cs`, `README.md` + +### New (SDK) +- `Core/ILogTideClient.cs` +- `Core/LogTideClient.cs` (rewrite) +- `Core/LogTideScope.cs` +- `Transport/ITransport.cs` +- `Transport/LogTideHttpTransport.cs` +- `Transport/OtlpHttpTransport.cs` +- `Transport/BatchTransport.cs` +- `Tracing/Span.cs` +- `Tracing/SpanStatus.cs` +- `Tracing/SpanEvent.cs` +- `Tracing/SpanManager.cs` +- `Tracing/W3CTraceContext.cs` +- `Integrations/IIntegration.cs` +- `Integrations/GlobalErrorIntegration.cs` +- `Breadcrumbs/Breadcrumb.cs` +- `Breadcrumbs/BreadcrumbBuffer.cs` +- `Middleware/LogTideErrorHandlerMiddleware.cs` + +### New (Serilog project) +- `Serilog/LogTide.SDK.Serilog.csproj` +- `Serilog/LogTideSink.cs` +- `Serilog/LogTideSinkExtensions.cs` + +### New (Tests) +- `tests/LogTide.SDK.Tests/Helpers/FakeTransport.cs` +- `tests/LogTide.SDK.Tests/Helpers/FakeHttpMessageHandler.cs` +- `tests/LogTide.SDK.Tests/Core/LogTideScopeTests.cs` +- `tests/LogTide.SDK.Tests/Core/LogTideClientTests.cs` (rewrite) +- `tests/LogTide.SDK.Tests/Transport/BatchTransportTests.cs` +- `tests/LogTide.SDK.Tests/Tracing/W3CTraceContextTests.cs` +- `tests/LogTide.SDK.Tests/Tracing/SpanTests.cs` +- `tests/LogTide.SDK.Tests/Tracing/SpanManagerTests.cs` +- `tests/LogTide.SDK.Tests/Breadcrumbs/BreadcrumbBufferTests.cs` +- `tests/LogTide.SDK.Tests/Integrations/GlobalErrorIntegrationTests.cs` +- `tests/LogTide.SDK.Tests/Middleware/LogTideMiddlewareTests.cs` +- `tests/LogTide.SDK.Tests/Internal/CircuitBreakerTests.cs` (update) +- `tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj` +- `tests/LogTide.SDK.Serilog.Tests/LogTideSinkTests.cs` + +--- + +## Task 1: Fix vulnerabilities + update csproj + +**Files:** +- Modify: `tests/LogTide.SDK.Tests.csproj` +- Modify: `LogTide.SDK.csproj` + +- [ ] Remove `System.Net.Http 4.3.4` and `System.Text.RegularExpressions 4.3.1` from `tests/LogTide.SDK.Tests.csproj` (both are inbox on net8, explicit pins force vulnerable versions — CVE-2018-8292, CVE-2019-0820) +- [ ] Replace `Moq` with `NSubstitute` in test csproj (``) +- [ ] Update `LogTide.SDK.csproj`: change `net6.0;net7.0;net8.0` → `net8.0;net9.0` +- [ ] Remove `Microsoft.AspNetCore.Http.Abstractions 2.2.0` explicit pin; replace with framework reference: `` (conditional on net8/net9) +- [ ] Remove `System.Text.Encodings.Web` explicit pin from SDK csproj — it is inbox on net8/net9; the explicit pin overrides the framework-supplied version unnecessarily +- [ ] Update `System.Text.Json` to `9.0.0`, `Microsoft.Extensions.Http` to `9.0.0` +- [ ] Set `13` +- [ ] Add `truemoderate` to SDK csproj +- [ ] Run `dotnet restore && dotnet build` — expect clean build +- [ ] Commit: `fix: remove vulnerable NuGet pins and drop EOL target frameworks` + +--- + +## Task 2: Update models (LogEntry + ClientOptions) + +**Files:** +- Modify: `Models/LogEntry.cs` +- Modify: `Models/ClientOptions.cs` +- Test: `tests/LogTide.SDK.Tests/Models/LogEntryTests.cs` (new) + +- [ ] Write failing test: `LogEntry_HasSpanIdAndSessionId` +```csharp +[Fact] +public void LogEntry_DefaultsAreCorrect() +{ + var entry = new LogEntry(); + Assert.Null(entry.SpanId); + Assert.Null(entry.SessionId); +} +``` +- [ ] Add to `LogEntry.cs`: +```csharp +[JsonPropertyName("span_id")] +public string? SpanId { get; set; } + +[JsonPropertyName("session_id")] +public string? SessionId { get; set; } +``` +- [ ] Update `SerializableLogEntry` and its `FromLogEntry` to map `SpanId`, `SessionId` +- [ ] Write failing test: `ClientOptions_ParsesDsn` +```csharp +[Fact] +public void ClientOptions_ParsesDsn() +{ + var opts = ClientOptions.FromDsn("https://lp_mykey@api.logtide.dev"); + Assert.Equal("https://api.logtide.dev", opts.ApiUrl); + Assert.Equal("lp_mykey", opts.ApiKey); +} +``` +- [ ] Add to `ClientOptions.cs`: +```csharp +public string? Dsn { get; set; } +public string ServiceName { get; set; } = "app"; +public double TracesSampleRate { get; set; } = 1.0; +public List Integrations { get; set; } = []; + +public static ClientOptions FromDsn(string dsn) +{ + var uri = new Uri(dsn); + return new ClientOptions + { + ApiUrl = $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}", + ApiKey = uri.UserInfo + }; +} + +internal void Resolve() +{ + if (string.IsNullOrEmpty(Dsn)) return; + var parsed = FromDsn(Dsn); + if (string.IsNullOrEmpty(ApiUrl)) ApiUrl = parsed.ApiUrl; + if (string.IsNullOrEmpty(ApiKey)) ApiKey = parsed.ApiKey; +} +``` +- [ ] Fix stale XML comment (`logward.dev` → `logtide.dev`) +- [ ] Run tests: `dotnet test tests/LogTide.SDK.Tests/ -v` — expect pass +- [ ] Commit: `feat: add SpanId/SessionId to LogEntry, DSN support and Integrations to ClientOptions` + +--- + +## Task 3: W3CTraceContext utility + +**Files:** +- Create: `Tracing/W3CTraceContext.cs` +- Test: `tests/LogTide.SDK.Tests/Tracing/W3CTraceContextTests.cs` + +- [ ] Write failing tests: +```csharp +public class W3CTraceContextTests +{ + [Fact] + public void Parse_ValidTraceparent_ReturnsIds() + { + var result = W3CTraceContext.Parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + Assert.NotNull(result); + Assert.Equal("4bf92f3577b34da6a3ce929d0e0e4736", result.Value.TraceId); + Assert.Equal("00f067aa0ba902b7", result.Value.SpanId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("invalid")] + [InlineData("00-short-00f067aa0ba902b7-01")] + public void Parse_InvalidInput_ReturnsNull(string? input) + { + Assert.Null(W3CTraceContext.Parse(input)); + } + + [Fact] + public void Create_ProducesValidFormat() + { + var header = W3CTraceContext.Create("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7"); + Assert.Matches(@"^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$", header); + } + + [Fact] + public void GenerateTraceId_IsLowercaseHex32() + { + var id = W3CTraceContext.GenerateTraceId(); + Assert.Equal(32, id.Length); + Assert.Matches("^[0-9a-f]{32}$", id); + } + + [Fact] + public void GenerateSpanId_IsLowercaseHex16() + { + var id = W3CTraceContext.GenerateSpanId(); + Assert.Equal(16, id.Length); + Assert.Matches("^[0-9a-f]{16}$", id); + } +} +``` +- [ ] Implement `Tracing/W3CTraceContext.cs`: +```csharp +namespace LogTide.SDK.Tracing; + +public static class W3CTraceContext +{ + public const string HeaderName = "traceparent"; + + public static (string TraceId, string SpanId)? Parse(string? traceparent) + { + if (string.IsNullOrEmpty(traceparent)) return null; + var parts = traceparent.Split('-'); + if (parts.Length != 4 || parts[0] != "00") return null; + if (parts[1].Length != 32 || parts[2].Length != 16) return null; + return (parts[1], parts[2]); + } + + public static string Create(string traceId, string spanId, bool sampled = true) => + $"00-{traceId}-{spanId}-{(sampled ? "01" : "00")}"; + + public static string GenerateTraceId() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + + public static string GenerateSpanId() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add W3CTraceContext utility` + +--- + +## Task 4: Breadcrumbs + +**Files:** +- Create: `Breadcrumbs/Breadcrumb.cs` +- Create: `Breadcrumbs/BreadcrumbBuffer.cs` +- Test: `tests/LogTide.SDK.Tests/Breadcrumbs/BreadcrumbBufferTests.cs` + +- [ ] Write failing tests: +```csharp +public class BreadcrumbBufferTests +{ + [Fact] + public void Add_StoresItems() + { + var buf = new BreadcrumbBuffer(maxSize: 5); + buf.Add(new Breadcrumb { Message = "hello" }); + Assert.Single(buf.GetAll()); + } + + [Fact] + public void Add_EvictsOldestWhenFull() + { + var buf = new BreadcrumbBuffer(maxSize: 3); + buf.Add(new Breadcrumb { Message = "1" }); + buf.Add(new Breadcrumb { Message = "2" }); + buf.Add(new Breadcrumb { Message = "3" }); + buf.Add(new Breadcrumb { Message = "4" }); // should evict "1" + var all = buf.GetAll(); + Assert.Equal(3, all.Count); + Assert.Equal("2", all[0].Message); + Assert.Equal("4", all[2].Message); + } + + [Fact] + public void GetAll_ReturnsSnapshot() + { + var buf = new BreadcrumbBuffer(2); + buf.Add(new Breadcrumb { Message = "a" }); + var snap1 = buf.GetAll(); + buf.Add(new Breadcrumb { Message = "b" }); + Assert.Single(snap1); // snapshot unchanged + } +} +``` +- [ ] Implement `Breadcrumbs/Breadcrumb.cs`: +```csharp +namespace LogTide.SDK.Breadcrumbs; + +public sealed class Breadcrumb +{ + public string Type { get; set; } = "custom"; + public string Message { get; set; } = string.Empty; + public string? Level { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; + public Dictionary Data { get; set; } = new(); +} +``` +- [ ] Implement `Breadcrumbs/BreadcrumbBuffer.cs`: +```csharp +namespace LogTide.SDK.Breadcrumbs; + +internal sealed class BreadcrumbBuffer +{ + private readonly int _maxSize; + private readonly Queue _queue = new(); + private readonly object _lock = new(); + + public BreadcrumbBuffer(int maxSize = 50) => _maxSize = maxSize; + + public void Add(Breadcrumb breadcrumb) + { + lock (_lock) + { + if (_queue.Count >= _maxSize) _queue.Dequeue(); + _queue.Enqueue(breadcrumb); + } + } + + public IReadOnlyList GetAll() + { + lock (_lock) { return _queue.ToArray(); } + } +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add Breadcrumb and BreadcrumbBuffer` + +--- + +## Task 5: Fix CircuitBreaker HalfOpen + +**Files:** +- Modify: `Internal/CircuitBreaker.cs` +- Modify: `tests/LogTide.SDK.Tests/Internal/CircuitBreakerTests.cs` + +- [ ] Write new failing test: +```csharp +[Fact] +public void HalfOpen_AllowsOnlyOneProbe() +{ + var cb = new CircuitBreaker(threshold: 1, resetTimeoutMs: 0); + cb.RecordFailure(); // opens + Thread.Sleep(1); // let reset timeout pass + + Assert.True(cb.CanAttempt()); // first probe allowed + Assert.False(cb.CanAttempt()); // second blocked while probe in-flight +} + +[Fact] +public void HalfOpen_FailedProbeReopens() +{ + var cb = new CircuitBreaker(threshold: 1, resetTimeoutMs: 0); + cb.RecordFailure(); + Thread.Sleep(1); + cb.CanAttempt(); // allow probe + cb.RecordFailure(); // probe failed → reopen + Assert.Equal(CircuitState.Open, cb.State); +} +``` +- [ ] Update `Internal/CircuitBreaker.cs` — add `_halfOpenProbePending` flag: +```csharp +private bool _halfOpenProbePending; + +public bool CanAttempt() +{ + lock (_lock) + { + UpdateState(); + if (_state == CircuitState.Closed) return true; + if (_state == CircuitState.Open) return false; + // HalfOpen: allow exactly one probe + if (_halfOpenProbePending) return false; + _halfOpenProbePending = true; + return true; + } +} + +public void RecordSuccess() +{ + lock (_lock) + { + _failureCount = 0; + _halfOpenProbePending = false; + _state = CircuitState.Closed; + } +} + +public void RecordFailure() +{ + lock (_lock) + { + _failureCount++; + _halfOpenProbePending = false; + _lastFailureTime = DateTime.UtcNow; + if (_failureCount >= _threshold) _state = CircuitState.Open; + } +} +``` +- [ ] Run all circuit breaker tests — expect pass +- [ ] Commit: `fix: circuit breaker HalfOpen now allows exactly one probe` + +--- + +## Task 6: Span model + SpanManager + +**Files:** +- Create: `Tracing/Span.cs` (includes SpanStatus, SpanEvent) +- Create: `Tracing/SpanManager.cs` +- Test: `tests/LogTide.SDK.Tests/Tracing/SpanTests.cs` +- Test: `tests/LogTide.SDK.Tests/Tracing/SpanManagerTests.cs` + +- [ ] Write failing tests for Span: +```csharp +public class SpanTests +{ + [Fact] + public void Span_InitialState_IsCorrect() + { + var span = new Span("abc123", "trace123", null, "HTTP GET"); + Assert.Equal("abc123", span.SpanId); + Assert.Equal("trace123", span.TraceId); + Assert.Equal("HTTP GET", span.Name); + Assert.Equal(SpanStatus.Unset, span.Status); + Assert.False(span.IsFinished); + } + + [Fact] + public void Finish_SetsEndTimeAndStatus() + { + var span = new Span("a", "b", null, "test"); + span.Finish(SpanStatus.Ok); + Assert.True(span.IsFinished); + Assert.Equal(SpanStatus.Ok, span.Status); + Assert.NotNull(span.EndTime); + } + + [Fact] + public void SetAttribute_StoresValue() + { + var span = new Span("a", "b", null, "test"); + span.SetAttribute("http.method", "GET"); + Assert.Equal("GET", span.Attributes["http.method"]); + } + + [Fact] + public void AddEvent_AppendsToList() + { + var span = new Span("a", "b", null, "test"); + span.AddEvent("exception", new Dictionary { ["message"] = "oops" }); + Assert.Single(span.Events); + Assert.Equal("exception", span.Events[0].Name); + } +} +``` +- [ ] Implement `Tracing/Span.cs`: +```csharp +namespace LogTide.SDK.Tracing; + +public sealed class Span +{ + public string SpanId { get; } + public string TraceId { get; } + public string? ParentSpanId { get; } + public string Name { get; set; } + public DateTimeOffset StartTime { get; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EndTime { get; private set; } + public SpanStatus Status { get; private set; } = SpanStatus.Unset; + public Dictionary Attributes { get; } = new(); + public List Events { get; } = new(); + public bool IsFinished => EndTime.HasValue; + + public Span(string spanId, string traceId, string? parentSpanId, string name) + { + SpanId = spanId; TraceId = traceId; + ParentSpanId = parentSpanId; Name = name; + } + + public void SetStatus(SpanStatus status) => Status = status; + public void SetAttribute(string key, object? value) => Attributes[key] = value; + public void AddEvent(string name, Dictionary? attrs = null) + => Events.Add(new SpanEvent(name, DateTimeOffset.UtcNow, attrs ?? new())); + public void Finish(SpanStatus status = SpanStatus.Ok) + { + Status = status; + EndTime = DateTimeOffset.UtcNow; + } +} + +public sealed class SpanEvent +{ + public string Name { get; } + public DateTimeOffset Timestamp { get; } + public Dictionary Attributes { get; } + public SpanEvent(string name, DateTimeOffset ts, Dictionary attrs) + { Name = name; Timestamp = ts; Attributes = attrs; } +} + +public enum SpanStatus { Unset, Ok, Error } +``` +- [ ] Write failing tests for SpanManager: +```csharp +public class SpanManagerTests +{ + [Fact] + public void StartSpan_ReturnsSpanWithCorrectFields() + { + var mgr = new SpanManager(); + var span = mgr.StartSpan("test", "trace123"); + Assert.Equal("trace123", span.TraceId); + Assert.Equal("test", span.Name); + Assert.NotEmpty(span.SpanId); + } + + [Fact] + public void FinishSpan_RemovesFromActive() + { + var mgr = new SpanManager(); + var span = mgr.StartSpan("test", "t"); + Assert.True(mgr.TryFinishSpan(span.SpanId, SpanStatus.Ok, out _)); + Assert.False(mgr.TryFinishSpan(span.SpanId, SpanStatus.Ok, out _)); + } + + [Fact] + public void TryFinishSpan_UnknownId_ReturnsFalse() + { + var mgr = new SpanManager(); + Assert.False(mgr.TryFinishSpan("nonexistent", SpanStatus.Ok, out _)); + } +} +``` +- [ ] Implement `Tracing/SpanManager.cs`: +```csharp +namespace LogTide.SDK.Tracing; + +internal sealed class SpanManager +{ + private readonly ConcurrentDictionary _spans = new(); + + public Span StartSpan(string name, string traceId, string? parentSpanId = null) + { + var span = new Span(W3CTraceContext.GenerateSpanId(), traceId, parentSpanId, name); + _spans.TryAdd(span.SpanId, span); + return span; + } + + public bool TryFinishSpan(string spanId, SpanStatus status, out Span? span) + { + if (_spans.TryRemove(spanId, out span)) + { span.Finish(status); return true; } + return false; + } +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add Span model, SpanEvent, SpanStatus, SpanManager` + +--- + +## Task 7: ILogTideClient interface + LogTideScope + +**Must come before Task 8** — `IIntegration.Setup(ILogTideClient)` and tests using `Substitute.For()` require this interface to exist first. + +**Files:** +- Create: `Core/ILogTideClient.cs` +- Create: `Core/LogTideScope.cs` +- Test: `tests/LogTide.SDK.Tests/Core/LogTideScopeTests.cs` + +- [ ] Create `Core/ILogTideClient.cs`: +```csharp +namespace LogTide.SDK.Core; + +public interface ILogTideClient : IDisposable, IAsyncDisposable +{ + void Log(LogEntry entry); + void Debug(string service, string message, Dictionary? metadata = null); + void Info(string service, string message, Dictionary? metadata = null); + void Warn(string service, string message, Dictionary? metadata = null); + void Error(string service, string message, Dictionary? metadata = null); + void Error(string service, string message, Exception exception); + void Critical(string service, string message, Dictionary? metadata = null); + void Critical(string service, string message, Exception exception); + Task FlushAsync(CancellationToken cancellationToken = default); + Span StartSpan(string name, string? parentSpanId = null); + void AddBreadcrumb(Breadcrumb breadcrumb); + ClientMetrics GetMetrics(); + void ResetMetrics(); + CircuitState GetCircuitBreakerState(); +} +``` +- [ ] Write failing tests for LogTideScope: +```csharp +public class LogTideScopeTests +{ + [Fact] + public void Create_SetsCurrentScope() + { + using var scope = LogTideScope.Create("abc123"); + Assert.Equal("abc123", LogTideScope.Current?.TraceId); + } + + [Fact] + public void Dispose_RestoresPreviousScope() + { + using var outer = LogTideScope.Create("outer"); + using (var inner = LogTideScope.Create("inner")) + { + Assert.Equal("inner", LogTideScope.Current?.TraceId); + } + Assert.Equal("outer", LogTideScope.Current?.TraceId); + } + + [Fact] + public void Create_WithNullTraceId_GeneratesId() + { + using var scope = LogTideScope.Create(); + Assert.NotNull(scope.TraceId); + Assert.Equal(32, scope.TraceId.Length); + } + + [Fact] + public async Task AsyncLocal_IsolatesAcrossAsyncContexts() + { + string? traceInTask = null; + using var scope = LogTideScope.Create("main-trace"); + + await Task.Run(() => + { + using var inner = LogTideScope.Create("task-trace"); + traceInTask = LogTideScope.Current?.TraceId; + }); + + // After task finishes, main context unchanged + Assert.Equal("main-trace", LogTideScope.Current?.TraceId); + Assert.Equal("task-trace", traceInTask); + } + + [Fact] + public void AddBreadcrumb_StoredInScope() + { + using var scope = LogTideScope.Create("t"); + scope.AddBreadcrumb(new Breadcrumb { Message = "click" }); + Assert.Single(scope.GetBreadcrumbs()); + } +} +``` +- [ ] Implement `Core/LogTideScope.cs`: +```csharp +namespace LogTide.SDK.Core; + +public sealed class LogTideScope : IDisposable +{ + private static readonly AsyncLocal _current = new(); + + public static LogTideScope? Current => _current.Value; + + public string TraceId { get; } + public string? SpanId { get; internal set; } + public string? SessionId { get; set; } + + private readonly BreadcrumbBuffer _breadcrumbs = new(maxSize: 50); + private readonly LogTideScope? _previous; + + private LogTideScope(string traceId) + { + TraceId = traceId; + _previous = _current.Value; + _current.Value = this; + } + + public static LogTideScope Create(string? traceId = null) => + new(traceId ?? W3CTraceContext.GenerateTraceId()); + + public void AddBreadcrumb(Breadcrumb breadcrumb) => _breadcrumbs.Add(breadcrumb); + public IReadOnlyList GetBreadcrumbs() => _breadcrumbs.GetAll(); + + private bool _disposed; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _current.Value = _previous; + } +} +``` +- [ ] Add a concurrent-isolation test to `LogTideScopeTests`: +```csharp +[Fact] +public void Dispose_IsIdempotent() +{ + var scope = LogTideScope.Create("t"); + scope.Dispose(); + scope.Dispose(); // should not throw or corrupt state +} + +[Fact] +public async Task ConcurrentRequests_HaveIsolatedScopes() +{ + string? trace1 = null, trace2 = null; + var t1 = Task.Run(() => { using var s = LogTideScope.Create("req-1"); trace1 = LogTideScope.Current?.TraceId; }); + var t2 = Task.Run(() => { using var s = LogTideScope.Create("req-2"); trace2 = LogTideScope.Current?.TraceId; }); + await Task.WhenAll(t1, t2); + Assert.Equal("req-1", trace1); + Assert.Equal("req-2", trace2); +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add ILogTideClient interface and AsyncLocal LogTideScope` + +--- + +## Task 8: IIntegration + GlobalErrorIntegration + +**Files:** +- Create: `Integrations/IIntegration.cs` +- Create: `Integrations/GlobalErrorIntegration.cs` +- Test: `tests/LogTide.SDK.Tests/Integrations/GlobalErrorIntegrationTests.cs` + +- [ ] Create `Integrations/IIntegration.cs`: +```csharp +namespace LogTide.SDK.Integrations; + +public interface IIntegration +{ + string Name { get; } + void Setup(ILogTideClient client); + void Teardown(); +} +``` +- [ ] Write failing test: +```csharp +public class GlobalErrorIntegrationTests +{ + [Fact] + public void Setup_RegistersHandlers_TeardownUnregisters() + { + var client = Substitute.For(); + var integration = new GlobalErrorIntegration(); + integration.Setup(client); + Assert.Equal("GlobalError", integration.Name); + integration.Teardown(); // should not throw + } + + [Fact] + public void OnUnobservedTaskException_CallsClientError() + { + var client = Substitute.For(); + var integration = new GlobalErrorIntegration(); + integration.Setup(client); + + var ex = new AggregateException(new InvalidOperationException("oops")); + integration.SimulateUnobservedTaskException(ex); + + client.Received(1).Error(Arg.Any(), Arg.Any(), Arg.Any()); + integration.Teardown(); + } +} +``` +- [ ] Implement `Integrations/GlobalErrorIntegration.cs` with internal test seam: +```csharp +namespace LogTide.SDK.Integrations; + +public sealed class GlobalErrorIntegration : IIntegration +{ + private ILogTideClient? _client; + public string Name => "GlobalError"; + + public void Setup(ILogTideClient client) + { + _client = client; + AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + } + + public void Teardown() + { + AppDomain.CurrentDomain.UnhandledException -= OnUnhandledException; + TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; + } + + private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception ex) + _client?.Critical("global", "Unhandled exception", ex); + } + + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + _client?.Error("global", "Unobserved task exception", e.Exception); + e.SetObserved(); + } + + internal void SimulateUnobservedTaskException(AggregateException ex) + => OnUnobservedTaskException(null, new UnobservedTaskExceptionEventArgs(ex)); +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add IIntegration interface and GlobalErrorIntegration` + +--- + +## Task 9: Transport layer + +**Files:** +- Create: `Transport/ITransport.cs` +- Create: `Transport/LogTideHttpTransport.cs` +- Create: `Transport/OtlpHttpTransport.cs` +- Create: `Transport/BatchTransport.cs` +- Create: `tests/LogTide.SDK.Tests/Helpers/FakeTransport.cs` +- Test: `tests/LogTide.SDK.Tests/Transport/BatchTransportTests.cs` + +- [ ] Create `Transport/ITransport.cs`: +```csharp +namespace LogTide.SDK.Transport; + +internal interface ILogTransport +{ + Task SendAsync(IReadOnlyList logs, CancellationToken ct = default); +} + +internal interface ISpanTransport +{ + Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default); +} +``` +- [ ] Create `tests/LogTide.SDK.Tests/Helpers/FakeTransport.cs`: +```csharp +internal sealed class FakeTransport : ILogTransport, ISpanTransport +{ + public List> LogBatches { get; } = new(); + public List> SpanBatches { get; } = new(); + public int CallCount => LogBatches.Count; + public Exception? ThrowOn { get; set; } + private int _failFirstN; + + /// Throw on the first N calls, then succeed. + public void FailFirstN(int n) => _failFirstN = n; + + public Task SendAsync(IReadOnlyList logs, CancellationToken ct = default) + { + if (_failFirstN > 0) { _failFirstN--; throw ThrowOn ?? new HttpRequestException("fake failure"); } + if (ThrowOn != null) throw ThrowOn; + LogBatches.Add(logs); + return Task.CompletedTask; + } + + public Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default) + { + SpanBatches.Add(spans); + return Task.CompletedTask; + } +} +``` +- [ ] Write failing BatchTransport tests: +```csharp +public class BatchTransportTests +{ + private static ClientOptions Opts(int batchSize = 100, int flushMs = 60000) => new() + { + ApiUrl = "http://localhost", ApiKey = "k", + BatchSize = batchSize, FlushIntervalMs = flushMs, + MaxRetries = 0, RetryDelayMs = 0 + }; + + [Fact] + public async Task Enqueue_TriggersBatchFlush_WhenBatchSizeReached() + { + var fake = new FakeTransport(); + await using var transport = new BatchTransport(fake, fake, Opts(batchSize: 2)); + + var e1 = new LogEntry { Service = "s", Message = "1" }; + var e2 = new LogEntry { Service = "s", Message = "2" }; + transport.Enqueue(e1); + transport.Enqueue(e2); + await transport.FlushAsync(); + + Assert.Single(fake.LogBatches); + Assert.Equal(2, fake.LogBatches[0].Count); + } + + [Fact] + public async Task FlushAsync_EmptyBuffer_DoesNothing() + { + var fake = new FakeTransport(); + await using var transport = new BatchTransport(fake, fake, Opts()); + await transport.FlushAsync(); + Assert.Empty(fake.LogBatches); + } + + [Fact] + public async Task Enqueue_DropsLog_WhenBufferFull() + { + var fake = new FakeTransport(); + var opts = Opts(); opts.MaxBufferSize = 2; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry()); + transport.Enqueue(new LogEntry()); + Assert.Throws(() => transport.Enqueue(new LogEntry())); + } + + [Fact] + public async Task SendAsync_RetriesOnTransientFailure_ThenSucceeds() + { + var fake = new FakeTransport(); + fake.FailFirstN(2); // fail twice, succeed on third attempt + var opts = Opts(); opts.MaxRetries = 3; opts.RetryDelayMs = 0; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry { Service = "svc", Message = "m" }); + await transport.FlushAsync(); + + Assert.Single(fake.LogBatches); // one successful send on third attempt + Assert.Equal(2, transport.GetMetrics().Retries); + } + + [Fact] + public async Task SendAsync_ExhaustsRetries_DropsLogs() + { + var fake = new FakeTransport(); + fake.FailFirstN(10); // always fail + var opts = Opts(); opts.MaxRetries = 2; opts.RetryDelayMs = 0; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry { Service = "svc", Message = "m" }); + await transport.FlushAsync(); + + Assert.Empty(fake.LogBatches); + Assert.Equal(1, transport.GetMetrics().LogsDropped); + } +} +``` +- [ ] Implement `Transport/LogTideHttpTransport.cs` (extracted from old `SendLogsAsync`): +```csharp +namespace LogTide.SDK.Transport; + +internal sealed class LogTideHttpTransport : ILogTransport +{ + private readonly HttpClient _httpClient; + + public LogTideHttpTransport(HttpClient httpClient) => _httpClient = httpClient; + + public async Task SendAsync(IReadOnlyList logs, CancellationToken ct = default) + { + var payload = new { logs = logs.Select(SerializableLogEntry.FromLogEntry) }; + var json = JsonSerializer.Serialize(payload, JsonConfig.Options); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync("/api/v1/ingest", content, ct); + if (!response.IsSuccessStatusCode) + throw new ApiException((int)response.StatusCode, + await response.Content.ReadAsStringAsync(ct)); + } +} +``` +- [ ] Implement `Transport/OtlpHttpTransport.cs`: +```csharp +namespace LogTide.SDK.Transport; + +internal sealed class OtlpHttpTransport : ISpanTransport +{ + private readonly HttpClient _httpClient; + private readonly string _serviceName; + + public OtlpHttpTransport(HttpClient httpClient, string serviceName) + { _httpClient = httpClient; _serviceName = serviceName; } + + public async Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default) + { + var payload = BuildOtlpPayload(spans); + var json = JsonSerializer.Serialize(payload, JsonConfig.Options); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync("/v1/otlp/traces", content, ct); + if (!response.IsSuccessStatusCode) + throw new ApiException((int)response.StatusCode, + await response.Content.ReadAsStringAsync(ct)); + } + + private object BuildOtlpPayload(IReadOnlyList spans) => new + { + resourceSpans = new[] + { + new + { + resource = new + { + attributes = new[] { new { key = "service.name", value = new { stringValue = _serviceName } } } + }, + scopeSpans = new[] + { + new { spans = spans.Select(ToOtlp).ToArray() } + } + } + } + }; + + private static object ToOtlp(Span s) => new + { + traceId = s.TraceId, + spanId = s.SpanId, + parentSpanId = s.ParentSpanId, + name = s.Name, + startTimeUnixNano = s.StartTime.ToUnixTimeMilliseconds() * 1_000_000L, + endTimeUnixNano = (s.EndTime ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds() * 1_000_000L, + // SpanStatus enum: Unset=0, Ok=1, Error=2 — matches OTLP StatusCode exactly + status = new { code = (int)s.Status }, + attributes = s.Attributes.Select(kv => new { key = kv.Key, value = new { stringValue = kv.Value?.ToString() } }).ToArray(), + events = s.Events.Select(e => new { name = e.Name, timeUnixNano = e.Timestamp.ToUnixTimeMilliseconds() * 1_000_000L }).ToArray() + }; +} +``` +- [ ] Implement `Transport/BatchTransport.cs` — moves buffering/retry/CB logic out of LogTideClient: +```csharp +namespace LogTide.SDK.Transport; + +internal sealed class BatchTransport : IDisposable, IAsyncDisposable +{ + private readonly ILogTransport _logTransport; + private readonly ISpanTransport? _spanTransport; + private readonly ClientOptions _options; + private readonly CircuitBreaker _circuitBreaker; + private readonly List _logBuffer = new(); + private readonly List _spanBuffer = new(); + private readonly object _lock = new(); + private readonly object _metricsLock = new(); + private readonly Timer _flushTimer; + private readonly List _latencyWindow = new(); + private ClientMetrics _metrics = new(); + private bool _disposed; + + public BatchTransport(ILogTransport logTransport, ISpanTransport? spanTransport, ClientOptions options) + { + _logTransport = logTransport; + _spanTransport = spanTransport; + _options = options; + _circuitBreaker = new CircuitBreaker(options.CircuitBreakerThreshold, options.CircuitBreakerResetMs); + _flushTimer = new Timer(_ => FireAndForgetFlush(), null, options.FlushIntervalMs, options.FlushIntervalMs); + } + + public void Enqueue(LogEntry entry) + { + bool shouldFlush; + lock (_lock) + { + if (_logBuffer.Count >= _options.MaxBufferSize) + { + lock (_metricsLock) { _metrics.LogsDropped++; } + throw new BufferFullException(); + } + _logBuffer.Add(entry); + shouldFlush = _logBuffer.Count >= _options.BatchSize; + } + // Fire flush AFTER releasing lock to avoid lock contention + if (shouldFlush) FireAndForgetFlush(); + } + + public void EnqueueSpan(Span span) + { + lock (_lock) { _spanBuffer.Add(span); } + } + + private void FireAndForgetFlush() + { + Task.Run(FlushAsync).ContinueWith(t => + { + if (t.IsFaulted && _options.Debug) + Console.WriteLine($"[LogTide] Flush error: {t.Exception?.GetBaseException().Message}"); + }, TaskContinuationOptions.OnlyOnFaulted); + } + + public async Task FlushAsync(CancellationToken ct = default) + { + if (_disposed) return; + List logs; + List spans; + lock (_lock) + { + if (_logBuffer.Count == 0 && _spanBuffer.Count == 0) return; + logs = new List(_logBuffer); + spans = new List(_spanBuffer); + _logBuffer.Clear(); + _spanBuffer.Clear(); + } + + if (logs.Count > 0) await SendWithRetryAsync(logs, ct); + if (spans.Count > 0 && _spanTransport != null) + await SendSpansWithRetryAsync(spans, ct); + } + + private async Task SendWithRetryAsync(List logs, CancellationToken ct) + { + var attempt = 0; + var delay = _options.RetryDelayMs; + while (attempt <= _options.MaxRetries) + { + try + { + if (!_circuitBreaker.CanAttempt()) + { + lock (_metricsLock) { _metrics.LogsDropped += logs.Count; _metrics.CircuitBreakerTrips++; } + throw new CircuitBreakerOpenException(); + } + var sw = Stopwatch.StartNew(); + await _logTransport.SendAsync(logs, ct).ConfigureAwait(false); + sw.Stop(); + _circuitBreaker.RecordSuccess(); + UpdateLatency(sw.Elapsed.TotalMilliseconds); + lock (_metricsLock) { _metrics.LogsSent += logs.Count; } + return; + } + catch (CircuitBreakerOpenException) { break; } + catch (Exception ex) + { + attempt++; + _circuitBreaker.RecordFailure(); + lock (_metricsLock) { _metrics.Errors++; if (attempt <= _options.MaxRetries) _metrics.Retries++; } + if (attempt > _options.MaxRetries) break; + await Task.Delay(delay, ct).ConfigureAwait(false); + delay *= 2; + } + } + lock (_metricsLock) { _metrics.LogsDropped += logs.Count; } + } + + private async Task SendSpansWithRetryAsync(List spans, CancellationToken ct) + { + // Always catch — never silently lose exceptions in production + try { await _spanTransport!.SendSpansAsync(spans, ct).ConfigureAwait(false); } + catch (Exception ex) + { + if (_options.Debug) + Console.WriteLine($"[LogTide] Span send error: {ex.Message}"); + // future: increment span drop metric here + } + } + + private void UpdateLatency(double ms) + { + lock (_metricsLock) + { + _latencyWindow.Add(ms); + if (_latencyWindow.Count > 100) _latencyWindow.RemoveAt(0); + _metrics.AvgLatencyMs = _latencyWindow.Average(); + } + } + + public ClientMetrics GetMetrics() { lock (_metricsLock) { return _metrics.Clone(); } } + public void ResetMetrics() { lock (_metricsLock) { _metrics = new(); _latencyWindow.Clear(); } } + public CircuitState CircuitBreakerState => _circuitBreaker.State; + + // Sync Dispose: stop timer but do NOT block on network flush. + // Callers should prefer `await using` to ensure logs are flushed. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _flushTimer.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + await _flushTimer.DisposeAsync().ConfigureAwait(false); + await FlushAsync().ConfigureAwait(false); + } +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: add composable transport layer (ILogTransport, ISpanTransport, BatchTransport, OtlpHttpTransport)` + +--- + +## Task 10: Rewrite LogTideClient + +**Files:** +- Rewrite: `Core/LogTideClient.cs` (replaces root `LogTideClient.cs`) +- Delete: root `LogTideClient.cs` +- Modify: `LogTide.SDK.csproj` — update `` +- Test: `tests/LogTide.SDK.Tests/Core/LogTideClientTests.cs` + +- [ ] Write failing tests: +```csharp +public class LogTideClientTests +{ + private static (LogTideClient client, FakeTransport transport) Create(Action? configure = null) + { + var opts = new ClientOptions { ApiUrl = "http://localhost", ApiKey = "k", FlushIntervalMs = 60000 }; + configure?.Invoke(opts); + var fake = new FakeTransport(); + var client = new LogTideClient(opts, fake, fake); + return (client, fake); + } + + [Fact] + public void Log_EnrichesFromAmbientScope() + { + var (client, fake) = Create(); + using var scope = LogTideScope.Create("trace-abc"); + LogEntry? captured = null; + // Use FakeTransport interception or expose internal buffer + client.Info("svc", "hello"); + // After flush, log should have TraceId = "trace-abc" + client.FlushAsync().Wait(); + Assert.Single(fake.LogBatches); + Assert.Equal("trace-abc", fake.LogBatches[0][0].TraceId); + } + + [Fact] + public void Log_MergesGlobalMetadata() + { + var (client, fake) = Create(o => o.GlobalMetadata = new() { ["env"] = "test" }); + client.Info("svc", "msg"); + client.FlushAsync().Wait(); + Assert.Equal("test", fake.LogBatches[0][0].Metadata["env"]); + } + + [Fact] + public void Constructor_NullOptions_Throws() + { + Assert.Throws(() => new LogTideClient(null!, new FakeTransport(), null)); + } + + [Fact] + public void StartSpan_ReturnSpanWithAmbientTraceId() + { + var (client, _) = Create(); + using var scope = LogTideScope.Create("my-trace"); + var span = client.StartSpan("HTTP GET /test"); + Assert.Equal("my-trace", span.TraceId); + Assert.Equal("HTTP GET /test", span.Name); + } + + [Fact] + public void AddBreadcrumb_StoredInCurrentScope() + { + var (client, _) = Create(); + using var scope = LogTideScope.Create("t"); + client.AddBreadcrumb(new Breadcrumb { Message = "btn click" }); + Assert.Single(scope.GetBreadcrumbs()); + } + + [Fact] + public void GetMetrics_ReturnsClone() + { + var (client, _) = Create(); + var m1 = client.GetMetrics(); + var m2 = client.GetMetrics(); + Assert.NotSame(m1, m2); + } +} +``` +- [ ] Implement `Core/LogTideClient.cs`: +```csharp +namespace LogTide.SDK.Core; + +public sealed class LogTideClient : ILogTideClient +{ + private readonly ClientOptions _options; + private readonly BatchTransport _transport; + private readonly SpanManager _spanManager = new(); + private readonly HttpClient _queryHttpClient; // dedicated client for query-only reads + private bool _disposed; + + // For DI with IHttpClientFactory + public LogTideClient(ClientOptions options, IHttpClientFactory httpClientFactory) + : this(options, + new LogTideHttpTransport(httpClientFactory.CreateClient("LogTide")), + new OtlpHttpTransport(httpClientFactory.CreateClient("LogTide"), options.ServiceName), + httpClientFactory.CreateClient("LogTide")) + { } + + // For testing / direct construction + internal LogTideClient(ClientOptions options, ILogTransport logTransport, ISpanTransport? spanTransport, + HttpClient? queryHttpClient = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _options.Resolve(); // parse DSN if provided + _transport = new BatchTransport(logTransport, spanTransport, options); + // _queryHttpClient used for QueryAsync, GetByTraceIdAsync, GetAggregatedStatsAsync + _queryHttpClient = queryHttpClient ?? new HttpClient { BaseAddress = new Uri(options.ApiUrl.TrimEnd('/')) }; + _queryHttpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", options.ApiKey); + foreach (var integration in options.Integrations) + integration.Setup(this); + } + + public void Log(LogEntry entry) + { + if (_disposed) return; + var scope = LogTideScope.Current; + if (string.IsNullOrEmpty(entry.TraceId)) + entry.TraceId = scope?.TraceId ?? (_options.AutoTraceId ? W3CTraceContext.GenerateTraceId() : null); + if (string.IsNullOrEmpty(entry.SpanId)) + entry.SpanId = scope?.SpanId; + if (string.IsNullOrEmpty(entry.SessionId)) + entry.SessionId = scope?.SessionId; + + if (scope != null) + { + var crumbs = scope.GetBreadcrumbs(); + if (crumbs.Count > 0) entry.Metadata.TryAdd("breadcrumbs", crumbs); + } + foreach (var kvp in _options.GlobalMetadata) + entry.Metadata.TryAdd(kvp.Key, kvp.Value); + + _transport.Enqueue(entry); + } + + public void Debug(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Debug, Message = message, Metadata = metadata ?? new() }); + public void Info(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Info, Message = message, Metadata = metadata ?? new() }); + public void Warn(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Warn, Message = message, Metadata = metadata ?? new() }); + public void Error(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Error, Message = message, Metadata = metadata ?? new() }); + public void Error(string service, string message, Exception exception) + => Log(new LogEntry { Service = service, Level = LogLevel.Error, Message = message, Metadata = new() { ["error"] = SerializeException(exception) } }); + public void Critical(string service, string message, Dictionary? metadata = null) + => Log(new LogEntry { Service = service, Level = LogLevel.Critical, Message = message, Metadata = metadata ?? new() }); + public void Critical(string service, string message, Exception exception) + => Log(new LogEntry { Service = service, Level = LogLevel.Critical, Message = message, Metadata = new() { ["error"] = SerializeException(exception) } }); + + public Task FlushAsync(CancellationToken ct = default) => _transport.FlushAsync(ct); + + public Span StartSpan(string name, string? parentSpanId = null) + { + var traceId = LogTideScope.Current?.TraceId ?? W3CTraceContext.GenerateTraceId(); + var span = _spanManager.StartSpan(name, traceId, parentSpanId); + if (LogTideScope.Current != null) LogTideScope.Current.SpanId = span.SpanId; + return span; + } + + public void FinishSpan(Span span, SpanStatus status = SpanStatus.Ok) + { + if (_spanManager.TryFinishSpan(span.SpanId, status, out var finished) && finished != null) + _transport.EnqueueSpan(finished); + } + + public void AddBreadcrumb(Breadcrumb breadcrumb) + => LogTideScope.Current?.AddBreadcrumb(breadcrumb); + + public ClientMetrics GetMetrics() => _transport.GetMetrics(); + public void ResetMetrics() => _transport.ResetMetrics(); + public CircuitState GetCircuitBreakerState() => _transport.CircuitBreakerState; + + // Query methods: copy QueryAsync, GetByTraceIdAsync, GetAggregatedStatsAsync from old LogTideClient.cs + // Replace all _httpClient references with _queryHttpClient + // Add .ConfigureAwait(false) to all awaits in these methods + + private static Dictionary SerializeException(Exception ex) + { + var r = new Dictionary { ["type"] = ex.GetType().FullName, ["message"] = ex.Message, ["stack"] = ex.StackTrace }; + if (ex.InnerException != null) r["cause"] = SerializeException(ex.InnerException); + return r; + } + + // Sync Dispose: stops the timer but does NOT flush (would deadlock in sync contexts). + // Always prefer `await using` in production to guarantee log delivery. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var i in _options.Integrations) i.Teardown(); + _transport.Dispose(); // stops timer only, no network call + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + foreach (var i in _options.Integrations) i.Teardown(); + await _transport.DisposeAsync().ConfigureAwait(false); + } +} +``` +- [ ] Copy `QueryAsync`, `GetByTraceIdAsync`, `GetAggregatedStatsAsync` verbatim from the old `LogTideClient.cs`, replacing all `_httpClient` references with `_queryHttpClient` and adding `.ConfigureAwait(false)` to all `await` calls +- [ ] Delete old `LogTideClient.cs` from root +- [ ] Run tests — expect pass +- [ ] Commit: `feat: rewrite LogTideClient as thin façade over BatchTransport with AsyncLocal scope enrichment` + +--- + +## Task 11: Update middleware + +**Files:** +- Modify: `Middleware/LogTideMiddleware.cs` +- Modify: `Middleware/LogTideExtensions.cs` +- Create: `Middleware/LogTideErrorHandlerMiddleware.cs` +- Test: `tests/LogTide.SDK.Tests/Middleware/LogTideMiddlewareTests.cs` + +- [ ] Write failing middleware tests: +```csharp +public class LogTideMiddlewareTests +{ + private static readonly HashSet SensitiveHeaders = + new(StringComparer.OrdinalIgnoreCase) { "authorization", "cookie", "x-api-key" }; + + [Fact] + public async Task Middleware_SetsTraceIdFromTraceparent() + { + var fake = new FakeTransport(); + var opts = new ClientOptions { ApiUrl = "http://localhost", ApiKey = "k", FlushIntervalMs = 60000 }; + var client = new LogTideClient(opts, fake, null); + + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["traceparent"] = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + var middleware = new LogTideMiddleware( + _ => Task.CompletedTask, + new LogTideMiddlewareOptions { ServiceName = "test" }, + client); + + await middleware.InvokeAsync(ctx); + await client.FlushAsync(); + + var loggedTraceId = fake.LogBatches.SelectMany(b => b).First().TraceId; + Assert.Equal("4bf92f3577b34da6a3ce929d0e0e4736", loggedTraceId); + } + + [Fact] + public async Task Middleware_FiltersSensitiveHeaders() + { + var fake = new FakeTransport(); + var opts = new ClientOptions { ApiUrl = "http://localhost", ApiKey = "k", FlushIntervalMs = 60000 }; + var client = new LogTideClient(opts, fake, null); + + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["Authorization"] = "Bearer secret"; + ctx.Request.Headers["X-Custom"] = "safe"; + + var middlewareOpts = new LogTideMiddlewareOptions { ServiceName = "test", IncludeHeaders = true }; + var middleware = new LogTideMiddleware(_ => Task.CompletedTask, middlewareOpts, client); + + await middleware.InvokeAsync(ctx); + await client.FlushAsync(); + + var headers = fake.LogBatches.SelectMany(b => b) + .First().Metadata["headers"] as Dictionary; + Assert.DoesNotContain("Authorization", headers!.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Contains("X-Custom", headers.Keys); + } +} +``` +- [ ] Update `Middleware/LogTideMiddleware.cs`: + - Add static `SensitiveHeaders` set: `{ "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", "proxy-authorization" }` + - Replace `GetOrGenerateTraceId` to parse `traceparent` via `W3CTraceContext.Parse()`, fall back to `X-Trace-Id`, fall back to generate + - In `InvokeAsync`: create `LogTideScope` with the traceId, call `client.StartSpan(...)`, finish span on response + - Emit `traceparent` response header via `W3CTraceContext.Create()` + - In `LogRequest`: when `IncludeHeaders`, filter with `SensitiveHeaders` + - Middleware constructor: take `ILogTideClient` from DI (not from options) + +- [ ] Update `Middleware/LogTideExtensions.cs`: + - `AddLogTide(IServiceCollection, ClientOptions)` → register `IHttpClientFactory` named client, register `ILogTideClient` as singleton + - `AddLogTide(IServiceCollection, Action)` overload + - `UseLogTideErrors()` extension that adds `LogTideErrorHandlerMiddleware` + +- [ ] Create `Middleware/LogTideErrorHandlerMiddleware.cs`: +```csharp +public class LogTideErrorHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogTideClient _client; + private readonly string _serviceName; + + public LogTideErrorHandlerMiddleware(RequestDelegate next, ILogTideClient client, string serviceName = "aspnet-api") + { _next = next; _client = client; _serviceName = serviceName; } + + public async Task InvokeAsync(HttpContext context) + { + try { await _next(context); } + catch (Exception ex) + { + _client.Error(_serviceName, $"Unhandled exception: {ex.Message}", ex); + throw; + } + } +} +``` +- [ ] Run tests — expect pass +- [ ] Commit: `feat: update middleware with W3C traceparent, scope, spans, sensitive header filtering, error handler middleware` + +--- + +## Task 12: Serilog sink project + +**Files:** +- Create: `Serilog/LogTide.SDK.Serilog.csproj` +- Create: `Serilog/LogTideSink.cs` +- Create: `Serilog/LogTideSinkExtensions.cs` +- Modify: `LogTide.SDK.sln` +- Create: `tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj` +- Create: `tests/LogTide.SDK.Serilog.Tests/LogTideSinkTests.cs` + +- [ ] Create `Serilog/LogTide.SDK.Serilog.csproj`: +```xml + + + net8.0;net9.0 + enable + enable + 13 + 0.2.0 + LogTide.SDK.Serilog + true + + + + + + +``` +- [ ] Create `tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj`: +```xml + + + net8.0 + enable + enable + 13 + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + +``` +- [ ] Write failing tests: +```csharp +public class LogTideSinkTests +{ + [Fact] + public void Emit_MapsLevelsCorrectly() + { + var client = Substitute.For(); + var sink = new LogTideSink(client, "test-svc"); + + var levels = new[] + { + (LogEventLevel.Verbose, LogLevel.Debug), + (LogEventLevel.Debug, LogLevel.Debug), + (LogEventLevel.Information, LogLevel.Info), + (LogEventLevel.Warning, LogLevel.Warn), + (LogEventLevel.Error, LogLevel.Error), + (LogEventLevel.Fatal, LogLevel.Critical), + }; + + foreach (var (serilogLevel, expectedLevel) in levels) + { + var evt = new LogEvent(DateTimeOffset.UtcNow, serilogLevel, null, + MessageTemplate.Empty, Array.Empty()); + sink.Emit(evt); + } + + client.Received(6).Log(Arg.Any()); + } + + [Fact] + public void Emit_IncludesExceptionInMetadata() + { + LogEntry? captured = null; + var client = Substitute.For(); + client.When(c => c.Log(Arg.Any())) + .Do(ci => captured = ci.Arg()); + + var sink = new LogTideSink(client, "svc"); + var ex = new InvalidOperationException("boom"); + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, ex, + MessageTemplate.Empty, Array.Empty()); + + sink.Emit(evt); + + Assert.NotNull(captured); + Assert.True(captured!.Metadata.ContainsKey("error")); + } + + [Fact] + public void Emit_MapsStructuredPropertiesToMetadata() + { + LogEntry? captured = null; + var client = Substitute.For(); + client.When(c => c.Log(Arg.Any())) + .Do(ci => captured = ci.Arg()); + + var sink = new LogTideSink(client, "svc"); + var props = new[] { new LogEventProperty("UserId", new ScalarValue(42)) }; + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, + new MessageTemplate("Hello {UserId}", Array.Empty()), props); + + sink.Emit(evt); + + Assert.Equal(42, captured!.Metadata["UserId"]); + } +} +``` +- [ ] Implement `Serilog/LogTideSink.cs`: +```csharp +using Serilog.Core; +using Serilog.Events; +using LogTide.SDK.Core; +using LogTide.SDK.Enums; +using LogTide.SDK.Models; +using SerilogLogLevel = Serilog.Events.LogEventLevel; + +namespace LogTide.SDK.Serilog; + +public sealed class LogTideSink : ILogEventSink +{ + private readonly ILogTideClient _client; + private readonly string _serviceName; + + public LogTideSink(ILogTideClient client, string serviceName = "app") + { _client = client; _serviceName = serviceName; } + + public void Emit(LogEvent logEvent) + { + var metadata = new Dictionary(); + foreach (var p in logEvent.Properties) + metadata[p.Key] = ExtractValue(p.Value); + if (logEvent.Exception != null) + metadata["error"] = SerializeException(logEvent.Exception); + + _client.Log(new LogEntry + { + Service = _serviceName, + Level = MapLevel(logEvent.Level), + Message = logEvent.RenderMessage(), + Metadata = metadata + }); + } + + private static LogLevel MapLevel(SerilogLogLevel level) => level switch + { + SerilogLogLevel.Verbose or SerilogLogLevel.Debug => LogLevel.Debug, + SerilogLogLevel.Information => LogLevel.Info, + SerilogLogLevel.Warning => LogLevel.Warn, + SerilogLogLevel.Error => LogLevel.Error, + SerilogLogLevel.Fatal => LogLevel.Critical, + _ => LogLevel.Info + }; + + private static object? ExtractValue(LogEventPropertyValue value) => value switch + { + ScalarValue sv => sv.Value, + SequenceValue seq => seq.Elements.Select(ExtractValue).ToArray(), + StructureValue struc => struc.Properties.ToDictionary(p => p.Name, p => ExtractValue(p.Value) as object), + DictionaryValue dict => dict.Elements.ToDictionary( + kv => ExtractValue(kv.Key)?.ToString() ?? string.Empty, + kv => ExtractValue(kv.Value)), + _ => value.ToString() + }; + + private static Dictionary SerializeException(Exception ex) + { + var r = new Dictionary { ["type"] = ex.GetType().FullName, ["message"] = ex.Message, ["stack"] = ex.StackTrace }; + if (ex.InnerException != null) r["cause"] = SerializeException(ex.InnerException); + return r; + } +} +``` +- [ ] Implement `Serilog/LogTideSinkExtensions.cs`: +```csharp +using Serilog; +using Serilog.Configuration; +using Serilog.Events; +using LogTide.SDK.Core; + +namespace LogTide.SDK.Serilog; + +public static class LogTideSinkExtensions +{ + public static LoggerConfiguration LogTide( + this LoggerSinkConfiguration sinkConfiguration, + ILogTideClient client, + string serviceName = "app", + LogEventLevel minimumLevel = LogEventLevel.Verbose) => + sinkConfiguration.Sink(new LogTideSink(client, serviceName), minimumLevel); +} +``` +- [ ] Add `Serilog/LogTide.SDK.Serilog.csproj` to solution: `dotnet sln add Serilog/LogTide.SDK.Serilog.csproj` +- [ ] Add test project to solution: `dotnet sln add tests/LogTide.SDK.Serilog.Tests/` +- [ ] Run Serilog sink tests — expect pass +- [ ] Commit: `feat: add Serilog sink project LogTide.SDK.Serilog` + +--- + +## Task 13: Update examples + README + +**Files:** +- Modify: `examples/BasicExample.cs` +- Modify: `examples/AspNetCoreExample.cs` +- Modify: `examples/AdvancedExample.cs` +- Create: `examples/SerilogExample.cs` +- Modify: `README.md` + +- [ ] Update `BasicExample.cs` — use `ClientOptions.FromDsn()`, `LogTideScope.Create()`, W3C trace IDs +- [ ] Update `AspNetCoreExample.cs` — show `AddLogTide()` with `IHttpClientFactory`, `UseLogTide()`, `UseLogTideErrors()` +- [ ] Update `AdvancedExample.cs` — show span tracking, integrations, breadcrumbs +- [ ] Create `SerilogExample.cs` — minimal `WriteTo.LogTide(client)` setup +- [ ] Update `README.md`: + - Update installation section (new TFMs, NuGet package name) + - Quick start with DSN + - ASP.NET Core setup + - Serilog integration + - W3C traceparent note + - Span tracking + - Remove all `SetTraceId`/`WithTraceId` references (breaking change note) +- [ ] Commit: `docs: update examples and README for v0.2 refactor` + +--- + +## Task 14: Final integration test pass + +- [ ] Run full test suite: `dotnet test --verbosity normal` +- [ ] Verify `dotnet build` clean with no warnings on both TFMs: `dotnet build -f net8.0 && dotnet build -f net9.0` +- [ ] Run `dotnet list package --vulnerable` — expect no vulnerabilities +- [ ] Fix any failing tests +- [ ] Commit: `test: full integration pass, all tests green` + +--- + +## Breaking Changes Summary (for CHANGELOG) + +- `SetTraceId()`, `GetTraceId()`, `WithTraceId()`, `WithNewTraceId()` → removed; use `LogTideScope.Create(traceId)` +- `LogTideMiddlewareOptions.Client` → removed; client resolved from DI +- Default trace header changed: `X-Trace-Id` → W3C `traceparent` (configurable fallback) +- `LangVersion` now 13, `TargetFrameworks` now `net8.0;net9.0` +- `LogTideClient` now `sealed`, implements `ILogTideClient` +- `LogEntry` has new optional fields `SpanId`, `SessionId` diff --git a/examples/AdvancedExample.cs b/examples/AdvancedExample.cs index 27a7220..0dc7471 100644 --- a/examples/AdvancedExample.cs +++ b/examples/AdvancedExample.cs @@ -1,6 +1,9 @@ -using LogTide.SDK; +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Core; +using LogTide.SDK.Integrations; using LogTide.SDK.Models; using LogTide.SDK.Enums; +using LogTide.SDK.Tracing; // Advanced usage example with all features @@ -8,72 +11,76 @@ Console.WriteLine("==============================\n"); // Create client with full configuration -var client = new LogTideClient(new ClientOptions +var options = new ClientOptions { ApiUrl = "http://localhost:8080", ApiKey = "lp_your_api_key_here", - + ServiceName = "advanced-example", + // Batching BatchSize = 50, FlushIntervalMs = 3000, - + // Buffer management MaxBufferSize = 5000, - + // Retry logic MaxRetries = 3, RetryDelayMs = 500, - + // Circuit breaker CircuitBreakerThreshold = 3, CircuitBreakerResetMs = 10000, - + // Options EnableMetrics = true, Debug = true, - + + // Integrations + Integrations = [new GlobalErrorIntegration()], + // Global metadata added to every log GlobalMetadata = new Dictionary { ["environment"] = "development", - ["version"] = "1.0.0", + ["version"] = "2.0.0", ["machine"] = Environment.MachineName } -}); +}; -// 1. Basic logging with levels -Console.WriteLine("1. Basic Logging"); -client.Debug("advanced", "Debug message"); -client.Info("advanced", "Info message"); -client.Warn("advanced", "Warning message"); -client.Error("advanced", "Error message"); -client.Critical("advanced", "Critical message"); - -// 2. Trace ID context -Console.WriteLine("\n2. Trace ID Context"); - -// Manual trace ID -client.SetTraceId("trace-001"); -client.Info("advanced", "Log with manual trace ID"); -client.Info("advanced", "Another log with same trace"); -client.SetTraceId(null); - -// Scoped trace ID -client.WithTraceId("trace-002", () => -{ - client.Info("advanced", "Log inside scoped trace"); - client.Info("advanced", "Another log in scope"); -}); +await using var client = LogTideClient.FromDsn("https://lp_key@api.logtide.dev", options); -// Auto-generated trace ID -client.WithNewTraceId(() => +// 1. AsyncLocal Scope-based tracing (replaces SetTraceId/WithTraceId) +Console.WriteLine("1. Scope-based Tracing"); +using (var scope = LogTideScope.Create()) { - client.Info("advanced", "Log with auto-generated trace ID"); - Console.WriteLine($" Generated trace ID: {client.GetTraceId()}"); -}); + client.Info("advanced", "Log with auto-generated W3C trace ID"); + Console.WriteLine($" Trace ID: {scope.TraceId}"); + + // 2. Span tracking + Console.WriteLine("\n2. Span Tracking"); + var span = client.StartSpan("process-order"); + span.SetAttribute("order.id", "ORD-123"); + + client.Info("advanced", "Processing order"); + await Task.Delay(50); // simulate work -// 3. Custom log entries -Console.WriteLine("\n3. Custom Log Entries"); + span.AddEvent("validation-complete"); + await Task.Delay(50); + + client.FinishSpan(span, SpanStatus.Ok); + Console.WriteLine($" Span: {span.Name} ({span.SpanId})"); + + // 3. Breadcrumbs + Console.WriteLine("\n3. Breadcrumbs"); + client.AddBreadcrumb(new Breadcrumb { Message = "User clicked button", Type = "ui" }); + client.AddBreadcrumb(new Breadcrumb { Message = "API call started", Type = "http" }); + client.Info("advanced", "Action with breadcrumbs"); + Console.WriteLine($" Breadcrumbs: {scope.GetBreadcrumbs().Count}"); +} + +// 4. Custom log entries +Console.WriteLine("\n4. Custom Log Entries"); client.Log(new LogEntry { Service = "custom-service", @@ -82,53 +89,27 @@ Metadata = new Dictionary { ["custom"] = "data", - ["nested"] = new Dictionary - { - ["key"] = "value" - } + ["nested"] = new Dictionary { ["key"] = "value" } } }); -// 4. Error serialization -Console.WriteLine("\n4. Error Serialization"); +// 5. Error serialization +Console.WriteLine("\n5. Error Serialization"); try { - try - { - throw new InvalidOperationException("Inner exception"); - } - catch (Exception inner) - { - throw new ApplicationException("Outer exception", inner); - } + try { throw new InvalidOperationException("Inner exception"); } + catch (Exception inner) { throw new ApplicationException("Outer exception", inner); } } catch (Exception ex) { client.Error("advanced", "Nested exception example", ex); } -// 5. Metrics -Console.WriteLine("\n5. Checking Metrics"); +// 6. Metrics +Console.WriteLine("\n6. Metrics"); +await client.FlushAsync(); var metrics = client.GetMetrics(); Console.WriteLine($" Logs sent: {metrics.LogsSent}"); -Console.WriteLine($" Logs dropped: {metrics.LogsDropped}"); -Console.WriteLine($" Errors: {metrics.Errors}"); -Console.WriteLine($" Retries: {metrics.Retries}"); -Console.WriteLine($" Avg latency: {metrics.AvgLatencyMs:F2}ms"); -Console.WriteLine($" Circuit breaker trips: {metrics.CircuitBreakerTrips}"); Console.WriteLine($" Circuit state: {client.GetCircuitBreakerState()}"); -// 6. Manual flush -Console.WriteLine("\n6. Manual Flush"); -await client.FlushAsync(); -Console.WriteLine(" Flush completed"); - -// 7. Updated metrics after flush -metrics = client.GetMetrics(); -Console.WriteLine($"\n7. Updated Metrics"); -Console.WriteLine($" Logs sent: {metrics.LogsSent}"); - -// 8. Cleanup -Console.WriteLine("\n8. Cleanup"); -await client.DisposeAsync(); -Console.WriteLine(" Client disposed successfully"); +Console.WriteLine("\nDone!"); diff --git a/examples/AspNetCoreExample.cs b/examples/AspNetCoreExample.cs index ce62184..8be8010 100644 --- a/examples/AspNetCoreExample.cs +++ b/examples/AspNetCoreExample.cs @@ -1,4 +1,4 @@ -using LogTide.SDK; +using LogTide.SDK.Core; using LogTide.SDK.Middleware; using LogTide.SDK.Models; @@ -6,11 +6,12 @@ var builder = WebApplication.CreateBuilder(args); -// Add LogTide client to DI container +// Add LogTide client with IHttpClientFactory builder.Services.AddLogTide(new ClientOptions { ApiUrl = builder.Configuration["LogTide:ApiUrl"] ?? "http://localhost:8080", ApiKey = builder.Configuration["LogTide:ApiKey"] ?? "lp_your_api_key_here", + ServiceName = "aspnet-example", Debug = builder.Environment.IsDevelopment(), GlobalMetadata = new Dictionary { @@ -21,7 +22,10 @@ var app = builder.Build(); -// Add LogTide middleware for automatic HTTP logging +// Add error handler middleware (catches unhandled exceptions) +app.UseLogTideErrors(); + +// Add LogTide middleware for automatic HTTP logging with W3C traceparent app.UseLogTide(options => { options.ServiceName = "aspnet-example"; @@ -32,60 +36,35 @@ options.SkipPaths.Add("/favicon.ico"); }); -// Health check endpoint (will be skipped by middleware) +// Health check endpoint (skipped by middleware) app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); -// Basic endpoint -app.MapGet("/", (LogTideClient logger) => +// Basic endpoint — ILogTideClient resolved from DI +app.MapGet("/", (ILogTideClient logger) => { logger.Info("aspnet-example", "Home page accessed"); return Results.Ok(new { message = "Hello, World!" }); }); // Endpoint with custom logging -app.MapGet("/users/{id}", (int id, LogTideClient logger) => +app.MapGet("/users/{id}", (int id, ILogTideClient logger) => { logger.Info("aspnet-example", $"Fetching user {id}", new Dictionary { ["userId"] = id }); - - return Results.Ok(new - { - id, - name = $"User {id}", - email = $"user{id}@example.com" - }); + + return Results.Ok(new { id, name = $"User {id}", email = $"user{id}@example.com" }); }); -// Endpoint that throws an error (will be logged by middleware) +// Endpoint that throws an error (caught by UseLogTideErrors) app.MapGet("/error", () => { throw new InvalidOperationException("This is a test error!"); }); -// Endpoint with trace ID context -app.MapGet("/process", async (LogTideClient logger) => -{ - await logger.WithTraceId(Guid.NewGuid().ToString(), async () => - { - logger.Info("aspnet-example", "Starting process"); - - await Task.Delay(100); // Simulate work - logger.Debug("aspnet-example", "Step 1 completed"); - - await Task.Delay(100); - logger.Debug("aspnet-example", "Step 2 completed"); - - await Task.Delay(100); - logger.Info("aspnet-example", "Process completed"); - }); - - return Results.Ok(new { status = "completed" }); -}); - // Metrics endpoint -app.MapGet("/metrics", (LogTideClient logger) => +app.MapGet("/metrics", (ILogTideClient logger) => { var metrics = logger.GetMetrics(); return Results.Ok(new @@ -93,9 +72,6 @@ await logger.WithTraceId(Guid.NewGuid().ToString(), async () => logsSent = metrics.LogsSent, logsDropped = metrics.LogsDropped, errors = metrics.Errors, - retries = metrics.Retries, - avgLatencyMs = metrics.AvgLatencyMs, - circuitBreakerTrips = metrics.CircuitBreakerTrips, circuitBreakerState = logger.GetCircuitBreakerState().ToString() }); }); @@ -103,7 +79,7 @@ await logger.WithTraceId(Guid.NewGuid().ToString(), async () => // Graceful shutdown app.Lifetime.ApplicationStopping.Register(async () => { - var logger = app.Services.GetRequiredService(); + var logger = app.Services.GetRequiredService(); await logger.FlushAsync(); Console.WriteLine("Logs flushed on shutdown"); }); diff --git a/examples/BasicExample.cs b/examples/BasicExample.cs index c37d650..43b3e07 100644 --- a/examples/BasicExample.cs +++ b/examples/BasicExample.cs @@ -1,4 +1,4 @@ -using LogTide.SDK; +using LogTide.SDK.Core; using LogTide.SDK.Models; // Basic usage example @@ -6,13 +6,16 @@ Console.WriteLine("LogTide SDK - Basic Example"); Console.WriteLine("===========================\n"); -// Create client -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "http://localhost:8080", - ApiKey = "lp_your_api_key_here", - Debug = true -}); +// Create client using DSN +await using var client = LogTideClient.FromDsn("https://lp_your_api_key@api.logtide.dev"); + +// Or create with explicit options: +// await using var client = new LogTideClient(new ClientOptions +// { +// ApiUrl = "http://localhost:8080", +// ApiKey = "lp_your_api_key_here", +// Debug = true +// }); // Basic logging client.Debug("example", "This is a debug message"); @@ -37,24 +40,17 @@ client.Error("example", "An error occurred", ex); } -// Critical logging -client.Critical("example", "System is shutting down", new Dictionary +// Scoped tracing with LogTideScope +using (var scope = LogTideScope.Create()) { - ["reason"] = "maintenance", - ["scheduled"] = true -}); - -Console.WriteLine("\nWaiting for logs to be sent..."); -await Task.Delay(2000); + client.Info("example", "This log has an auto-generated W3C trace ID"); + Console.WriteLine($"Trace ID: {scope.TraceId}"); +} // Get metrics var metrics = client.GetMetrics(); Console.WriteLine($"\n--- Metrics ---"); Console.WriteLine($"Logs sent: {metrics.LogsSent}"); -Console.WriteLine($"Logs dropped: {metrics.LogsDropped}"); -Console.WriteLine($"Errors: {metrics.Errors}"); Console.WriteLine($"Circuit breaker state: {client.GetCircuitBreakerState()}"); -// Cleanup -await client.DisposeAsync(); -Console.WriteLine("\nClient disposed. Done!"); +Console.WriteLine("\nDone!"); diff --git a/examples/QueryExample.cs b/examples/QueryExample.cs index 4f1baec..1f7dcb3 100644 --- a/examples/QueryExample.cs +++ b/examples/QueryExample.cs @@ -1,4 +1,4 @@ -using LogTide.SDK; +using LogTide.SDK.Core; using LogTide.SDK.Models; // Query API example @@ -6,11 +6,7 @@ Console.WriteLine("LogTide SDK - Query API Example"); Console.WriteLine("================================\n"); -var client = new LogTideClient(new ClientOptions -{ - ApiUrl = "http://localhost:8080", - ApiKey = "lp_your_api_key_here" -}); +await using var client = LogTideClient.FromDsn("https://lp_your_api_key@api.logtide.dev"); // First, send some test logs Console.WriteLine("Sending test logs..."); @@ -26,7 +22,7 @@ await client.FlushAsync(); Console.WriteLine("Test logs sent.\n"); -// Wait a moment for logs to be indexed +// Wait for indexing await Task.Delay(1000); // 1. Basic query @@ -38,7 +34,7 @@ Service = "query-example", Limit = 5 }); - + Console.WriteLine($" Found {result.Total} logs, showing {result.Logs.Count}:"); foreach (var log in result.Logs) { @@ -62,7 +58,7 @@ To = DateTime.UtcNow, Limit = 10 }); - + Console.WriteLine($" Found {result.Total} info logs in the last hour"); } catch (Exception ex) @@ -79,7 +75,7 @@ Query = "message 5", Limit = 10 }); - + Console.WriteLine($" Found {result.Total} logs matching 'message 5'"); } catch (Exception ex) @@ -89,27 +85,24 @@ // 4. Get logs by trace ID Console.WriteLine("\n4. Logs by Trace ID"); -var traceId = Guid.NewGuid().ToString(); - -// Send logs with trace ID -client.WithTraceId(traceId, () => +using (var scope = LogTideScope.Create()) { client.Info("query-example", "Step 1: Start"); client.Info("query-example", "Step 2: Processing"); client.Info("query-example", "Step 3: Complete"); -}); -await client.FlushAsync(); -await Task.Delay(500); + await client.FlushAsync(); + await Task.Delay(500); -try -{ - var traceLogs = await client.GetByTraceIdAsync(traceId); - Console.WriteLine($" Found {traceLogs.Count} logs for trace {traceId}"); -} -catch (Exception ex) -{ - Console.WriteLine($" Error: {ex.Message}"); + try + { + var traceLogs = await client.GetByTraceIdAsync(scope.TraceId); + Console.WriteLine($" Found {traceLogs.Count} logs for trace {scope.TraceId}"); + } + catch (Exception ex) + { + Console.WriteLine($" Error: {ex.Message}"); + } } // 5. Aggregated statistics @@ -122,11 +115,11 @@ To = DateTime.UtcNow, Interval = "1h" }); - + Console.WriteLine($" Time series entries: {stats.Timeseries.Count}"); Console.WriteLine($" Top services: {stats.TopServices.Count}"); Console.WriteLine($" Top errors: {stats.TopErrors.Count}"); - + if (stats.TopServices.Count > 0) { Console.WriteLine(" Top services:"); @@ -141,6 +134,4 @@ Console.WriteLine($" Error: {ex.Message}"); } -// Cleanup -await client.DisposeAsync(); Console.WriteLine("\nDone!"); diff --git a/examples/SerilogExample.cs b/examples/SerilogExample.cs new file mode 100644 index 0000000..0fc6fe3 --- /dev/null +++ b/examples/SerilogExample.cs @@ -0,0 +1,31 @@ +using LogTide.SDK.Core; +using LogTide.SDK.Models; +using LogTide.SDK.Serilog; +using Serilog; + +// Serilog integration example + +Console.WriteLine("LogTide SDK - Serilog Example"); +Console.WriteLine("============================\n"); + +// Create LogTide client +await using var logtideClient = LogTideClient.FromDsn("https://lp_your_key@api.logtide.dev"); + +// Configure Serilog to write to LogTide +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.LogTide(logtideClient, serviceName: "my-service") + .CreateLogger(); + +// Use Serilog as normal — logs are forwarded to LogTide +Log.Information("Application started"); +Log.Warning("This is a warning from Serilog"); +Log.Error(new InvalidOperationException("oops"), "An error occurred"); + +// Structured properties are mapped to LogTide metadata +Log.Information("User {UserId} logged in from {IpAddress}", 42, "192.168.1.1"); + +await logtideClient.FlushAsync(); +Log.CloseAndFlush(); + +Console.WriteLine("\nDone!"); diff --git a/tests/Breadcrumbs/BreadcrumbBufferTests.cs b/tests/Breadcrumbs/BreadcrumbBufferTests.cs new file mode 100644 index 0000000..54e9903 --- /dev/null +++ b/tests/Breadcrumbs/BreadcrumbBufferTests.cs @@ -0,0 +1,39 @@ +using Xunit; +using LogTide.SDK.Breadcrumbs; + +namespace LogTide.SDK.Tests.Breadcrumbs; + +public class BreadcrumbBufferTests +{ + [Fact] + public void Add_StoresItems() + { + var buf = new BreadcrumbBuffer(maxSize: 5); + buf.Add(new Breadcrumb { Message = "hello" }); + Assert.Single(buf.GetAll()); + } + + [Fact] + public void Add_EvictsOldestWhenFull() + { + var buf = new BreadcrumbBuffer(maxSize: 3); + buf.Add(new Breadcrumb { Message = "1" }); + buf.Add(new Breadcrumb { Message = "2" }); + buf.Add(new Breadcrumb { Message = "3" }); + buf.Add(new Breadcrumb { Message = "4" }); // should evict "1" + var all = buf.GetAll(); + Assert.Equal(3, all.Count); + Assert.Equal("2", all[0].Message); + Assert.Equal("4", all[2].Message); + } + + [Fact] + public void GetAll_ReturnsSnapshot() + { + var buf = new BreadcrumbBuffer(2); + buf.Add(new Breadcrumb { Message = "a" }); + var snap1 = buf.GetAll(); + buf.Add(new Breadcrumb { Message = "b" }); + Assert.Single(snap1); // snapshot unchanged + } +} diff --git a/tests/CircuitBreakerTests.cs b/tests/CircuitBreakerTests.cs index 7d54c5b..c331d35 100644 --- a/tests/CircuitBreakerTests.cs +++ b/tests/CircuitBreakerTests.cs @@ -96,4 +96,26 @@ public void HalfOpen_RecordFailure_ReopensCircuit() Assert.Equal(CircuitState.Open, breaker.State); Assert.False(breaker.CanAttempt()); } + + [Fact] + public void HalfOpen_AllowsOnlyOneProbe() + { + var cb = new CircuitBreaker(threshold: 1, resetTimeoutMs: 0); + cb.RecordFailure(); // opens + Thread.Sleep(1); // let reset timeout pass + + Assert.True(cb.CanAttempt()); // first probe allowed + Assert.False(cb.CanAttempt()); // second blocked while probe in-flight + } + + [Fact] + public void HalfOpen_FailedProbeReopens() + { + var cb = new CircuitBreaker(threshold: 1, resetTimeoutMs: 50); + cb.RecordFailure(); + Thread.Sleep(100); // let timeout pass to reach HalfOpen + cb.CanAttempt(); // allow probe + cb.RecordFailure(); // probe failed → reopen + Assert.False(cb.CanAttempt()); // should be Open, not HalfOpen yet + } } diff --git a/tests/Core/LogTideClientTests.cs b/tests/Core/LogTideClientTests.cs new file mode 100644 index 0000000..42120ac --- /dev/null +++ b/tests/Core/LogTideClientTests.cs @@ -0,0 +1,73 @@ +using Xunit; +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Core; +using LogTide.SDK.Models; +using LogTide.SDK.Tests.Helpers; + +namespace LogTide.SDK.Tests.Core; + +public class LogTideClientTests +{ + private static (LogTideClient client, FakeTransport transport) Create(Action? configure = null) + { + var opts = new ClientOptions { ApiUrl = "http://localhost", ApiKey = "k", FlushIntervalMs = 60000 }; + configure?.Invoke(opts); + var fake = new FakeTransport(); + var client = new LogTideClient(opts, fake, fake); + return (client, fake); + } + + [Fact] + public async Task Log_EnrichesFromAmbientScope() + { + var (client, fake) = Create(); + using var scope = LogTideScope.Create("trace-abc"); + client.Info("svc", "hello"); + await client.FlushAsync(); + Assert.Single(fake.LogBatches); + Assert.Equal("trace-abc", fake.LogBatches[0][0].TraceId); + } + + [Fact] + public async Task Log_MergesGlobalMetadata() + { + var (client, fake) = Create(o => o.GlobalMetadata = new() { ["env"] = "test" }); + client.Info("svc", "msg"); + await client.FlushAsync(); + Assert.Equal("test", fake.LogBatches[0][0].Metadata["env"]); + } + + [Fact] + public void Constructor_NullOptions_Throws() + { + Assert.Throws(() => new LogTideClient(null!, new FakeTransport(), null)); + } + + [Fact] + public void StartSpan_ReturnSpanWithAmbientTraceId() + { + var (client, _) = Create(); + using var scope = LogTideScope.Create("my-trace"); + var span = client.StartSpan("HTTP GET /test"); + Assert.Equal("my-trace", span.TraceId); + Assert.Equal("HTTP GET /test", span.Name); + } + + [Fact] + public void AddBreadcrumb_StoredInCurrentScope() + { + var (client, _) = Create(); + using var scope = LogTideScope.Create("t"); + client.AddBreadcrumb(new Breadcrumb { Message = "btn click" }); + Assert.Single(scope.GetBreadcrumbs()); + } + + [Fact] + public void GetMetrics_ReturnsClone() + { + var (client, _) = Create(); + var m1 = client.GetMetrics(); + var m2 = client.GetMetrics(); + Assert.NotSame(m1, m2); + } +} diff --git a/tests/Core/LogTideScopeTests.cs b/tests/Core/LogTideScopeTests.cs new file mode 100644 index 0000000..e6a4119 --- /dev/null +++ b/tests/Core/LogTideScopeTests.cs @@ -0,0 +1,78 @@ +using Xunit; +using LogTide.SDK.Breadcrumbs; +using LogTide.SDK.Core; + +namespace LogTide.SDK.Tests.Core; + +public class LogTideScopeTests +{ + [Fact] + public void Create_SetsCurrentScope() + { + using var scope = LogTideScope.Create("abc123"); + Assert.Equal("abc123", LogTideScope.Current?.TraceId); + } + + [Fact] + public void Dispose_RestoresPreviousScope() + { + using var outer = LogTideScope.Create("outer"); + using (var inner = LogTideScope.Create("inner")) + { + Assert.Equal("inner", LogTideScope.Current?.TraceId); + } + Assert.Equal("outer", LogTideScope.Current?.TraceId); + } + + [Fact] + public void Create_WithNullTraceId_GeneratesId() + { + using var scope = LogTideScope.Create(); + Assert.NotNull(scope.TraceId); + Assert.Equal(32, scope.TraceId.Length); + } + + [Fact] + public async Task AsyncLocal_IsolatesAcrossAsyncContexts() + { + string? traceInTask = null; + using var scope = LogTideScope.Create("main-trace"); + + await Task.Run(() => + { + using var inner = LogTideScope.Create("task-trace"); + traceInTask = LogTideScope.Current?.TraceId; + }); + + // After task finishes, main context unchanged + Assert.Equal("main-trace", LogTideScope.Current?.TraceId); + Assert.Equal("task-trace", traceInTask); + } + + [Fact] + public void AddBreadcrumb_StoredInScope() + { + using var scope = LogTideScope.Create("t"); + scope.AddBreadcrumb(new Breadcrumb { Message = "click" }); + Assert.Single(scope.GetBreadcrumbs()); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var scope = LogTideScope.Create("t"); + scope.Dispose(); + scope.Dispose(); // should not throw or corrupt state + } + + [Fact] + public async Task ConcurrentRequests_HaveIsolatedScopes() + { + string? trace1 = null, trace2 = null; + var t1 = Task.Run(() => { using var s = LogTideScope.Create("req-1"); trace1 = LogTideScope.Current?.TraceId; }); + var t2 = Task.Run(() => { using var s = LogTideScope.Create("req-2"); trace2 = LogTideScope.Current?.TraceId; }); + await Task.WhenAll(t1, t2); + Assert.Equal("req-1", trace1); + Assert.Equal("req-2", trace2); + } +} diff --git a/tests/Helpers/FakeTransport.cs b/tests/Helpers/FakeTransport.cs new file mode 100644 index 0000000..7cb2d5f --- /dev/null +++ b/tests/Helpers/FakeTransport.cs @@ -0,0 +1,30 @@ +using LogTide.SDK.Models; +using LogTide.SDK.Tracing; +using LogTide.SDK.Transport; + +namespace LogTide.SDK.Tests.Helpers; + +internal sealed class FakeTransport : ILogTransport, ISpanTransport +{ + public List> LogBatches { get; } = new(); + public List> SpanBatches { get; } = new(); + public int CallCount => LogBatches.Count; + public Exception? ThrowOn { get; set; } + private int _failFirstN; + + public void FailFirstN(int n) => _failFirstN = n; + + public Task SendAsync(IReadOnlyList logs, CancellationToken ct = default) + { + if (_failFirstN > 0) { _failFirstN--; throw ThrowOn ?? new HttpRequestException("fake failure"); } + if (ThrowOn != null) throw ThrowOn; + LogBatches.Add(logs); + return Task.CompletedTask; + } + + public Task SendSpansAsync(IReadOnlyList spans, CancellationToken ct = default) + { + SpanBatches.Add(spans); + return Task.CompletedTask; + } +} diff --git a/tests/Integrations/GlobalErrorIntegrationTests.cs b/tests/Integrations/GlobalErrorIntegrationTests.cs new file mode 100644 index 0000000..ae3c04d --- /dev/null +++ b/tests/Integrations/GlobalErrorIntegrationTests.cs @@ -0,0 +1,33 @@ +using Xunit; +using NSubstitute; +using LogTide.SDK.Core; +using LogTide.SDK.Integrations; + +namespace LogTide.SDK.Tests.Integrations; + +public class GlobalErrorIntegrationTests +{ + [Fact] + public void Setup_RegistersHandlers_TeardownUnregisters() + { + var client = Substitute.For(); + var integration = new GlobalErrorIntegration(); + integration.Setup(client); + Assert.Equal("GlobalError", integration.Name); + integration.Teardown(); // should not throw + } + + [Fact] + public void OnUnobservedTaskException_CallsClientError() + { + var client = Substitute.For(); + var integration = new GlobalErrorIntegration(); + integration.Setup(client); + + var ex = new AggregateException(new InvalidOperationException("oops")); + integration.SimulateUnobservedTaskException(ex); + + client.Received(1).Error(Arg.Any(), Arg.Any(), Arg.Any()); + integration.Teardown(); + } +} diff --git a/tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj b/tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj new file mode 100644 index 0000000..2b34d81 --- /dev/null +++ b/tests/LogTide.SDK.Serilog.Tests/LogTide.SDK.Serilog.Tests.csproj @@ -0,0 +1,26 @@ + + + net8.0 + enable + enable + 13 + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/tests/LogTide.SDK.Serilog.Tests/LogTideSinkTests.cs b/tests/LogTide.SDK.Serilog.Tests/LogTideSinkTests.cs new file mode 100644 index 0000000..ec55152 --- /dev/null +++ b/tests/LogTide.SDK.Serilog.Tests/LogTideSinkTests.cs @@ -0,0 +1,78 @@ +using Xunit; +using NSubstitute; +using Serilog.Events; +using Serilog.Parsing; +using LogTide.SDK.Core; +using LogTide.SDK.Enums; +using LogTide.SDK.Models; +using LogTide.SDK.Serilog; + +namespace LogTide.SDK.Tests.Serilog; + +public class LogTideSinkTests +{ + [Fact] + public void Emit_MapsLevelsCorrectly() + { + var client = Substitute.For(); + var sink = new LogTideSink(client, "test-svc"); + + var levels = new[] + { + LogEventLevel.Verbose, + LogEventLevel.Debug, + LogEventLevel.Information, + LogEventLevel.Warning, + LogEventLevel.Error, + LogEventLevel.Fatal, + }; + + foreach (var serilogLevel in levels) + { + var evt = new LogEvent(DateTimeOffset.UtcNow, serilogLevel, null, + MessageTemplate.Empty, Array.Empty()); + sink.Emit(evt); + } + + client.Received(6).Log(Arg.Any()); + } + + [Fact] + public void Emit_IncludesExceptionInMetadata() + { + LogEntry? captured = null; + var client = Substitute.For(); + client.When(c => c.Log(Arg.Any())) + .Do(ci => captured = ci.Arg()); + + var sink = new LogTideSink(client, "svc"); + var ex = new InvalidOperationException("boom"); + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Error, ex, + MessageTemplate.Empty, Array.Empty()); + + sink.Emit(evt); + + Assert.NotNull(captured); + Assert.True(captured!.Metadata.ContainsKey("error")); + } + + [Fact] + public void Emit_MapsStructuredPropertiesToMetadata() + { + LogEntry? captured = null; + var client = Substitute.For(); + client.When(c => c.Log(Arg.Any())) + .Do(ci => captured = ci.Arg()); + + var sink = new LogTideSink(client, "svc"); + var props = new[] { new LogEventProperty("UserId", new ScalarValue(42)) }; + var parser = new MessageTemplateParser(); + var template = parser.Parse("Hello {UserId}"); + var evt = new LogEvent(DateTimeOffset.UtcNow, LogEventLevel.Information, null, + template, props); + + sink.Emit(evt); + + Assert.Equal(42, captured!.Metadata["UserId"]); + } +} diff --git a/tests/LogTide.SDK.Tests.csproj b/tests/LogTide.SDK.Tests.csproj index 51e3b05..7d5ffb7 100644 --- a/tests/LogTide.SDK.Tests.csproj +++ b/tests/LogTide.SDK.Tests.csproj @@ -4,25 +4,28 @@ net8.0 enable enable + 13 false - - - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + diff --git a/tests/LogTideClientTests.cs b/tests/LogTideClientTests.cs index 71580e1..b00476c 100644 --- a/tests/LogTideClientTests.cs +++ b/tests/LogTideClientTests.cs @@ -1,172 +1,79 @@ using Xunit; +using LogTide.SDK.Core; using LogTide.SDK.Enums; using LogTide.SDK.Exceptions; using LogTide.SDK.Models; +using LogTide.SDK.Tests.Helpers; namespace LogTide.SDK.Tests; -public class LogTideClientTests +public class LogTideClientLegacyTests { - private static ClientOptions CreateTestOptions() => new() + private static (LogTideClient client, FakeTransport transport) Create(Action? configure = null) { - ApiUrl = "http://localhost:8080", - ApiKey = "lp_test_key", - FlushIntervalMs = 60000, // Long interval to prevent auto-flush during tests - Debug = false - }; - - [Fact] - public void Constructor_ThrowsOnNullOptions() - { - // Act & Assert - Assert.Throws(() => new LogTideClient(null!)); - } - - [Fact] - public void SetTraceId_SetsAndGetsTraceId() - { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - var traceId = "test-trace-123"; - - // Act - client.SetTraceId(traceId); - var result = client.GetTraceId(); - - // Assert - Assert.Equal(traceId, result); - } - - [Fact] - public void SetTraceId_Null_ClearsTraceId() - { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - client.SetTraceId("test-trace"); - - // Act - client.SetTraceId(null); - - // Assert - Assert.Null(client.GetTraceId()); - } - - [Fact] - public void WithTraceId_ScopedContext_RestoresPreviousTraceId() - { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - var originalTraceId = "original-trace"; - var scopedTraceId = "scoped-trace"; - client.SetTraceId(originalTraceId); - - // Act - string? insideTraceId = null; - client.WithTraceId(scopedTraceId, () => + var opts = new ClientOptions { - insideTraceId = client.GetTraceId(); - }); - - // Assert - Assert.Equal(scopedTraceId, insideTraceId); - Assert.Equal(originalTraceId, client.GetTraceId()); + ApiUrl = "http://localhost:8080", + ApiKey = "lp_test_key", + FlushIntervalMs = 60000, + Debug = false + }; + configure?.Invoke(opts); + var fake = new FakeTransport(); + var client = new LogTideClient(opts, fake, fake); + return (client, fake); } [Fact] - public void WithNewTraceId_GeneratesNewTraceId() + public void Constructor_ThrowsOnNullOptions() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - string? generatedTraceId = null; - - // Act - client.WithNewTraceId(() => - { - generatedTraceId = client.GetTraceId(); - }); - - // Assert - Assert.NotNull(generatedTraceId); - Assert.True(Guid.TryParse(generatedTraceId, out _)); + Assert.Throws(() => new LogTideClient(null!, new FakeTransport(), null)); } [Fact] public void Log_AddsToBuffer() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - - // Act + var (client, _) = Create(); client.Info("test", "Test message"); - - // Assert - metrics show no logs sent yet (still in buffer) var metrics = client.GetMetrics(); Assert.Equal(0, metrics.LogsSent); } [Fact] - public void Log_MergesGlobalMetadata() + public async Task Log_MergesGlobalMetadata() { - // Arrange - var options = CreateTestOptions(); - options.GlobalMetadata = new Dictionary - { - ["env"] = "test", - ["version"] = "1.0.0" - }; - using var client = new LogTideClient(options); - - // Act - log with partial metadata - client.Info("test", "Test message", new Dictionary - { - ["custom"] = "value" - }); - - // Assert - can't directly check buffer, but verify client doesn't throw - Assert.NotNull(client.GetMetrics()); + var (client, fake) = Create(o => + o.GlobalMetadata = new Dictionary { ["env"] = "test", ["version"] = "1.0.0" }); + client.Info("test", "Test message", new Dictionary { ["custom"] = "value" }); + await client.FlushAsync(); + Assert.Single(fake.LogBatches); + Assert.Equal("test", fake.LogBatches[0][0].Metadata["env"]); } [Fact] - public void Log_AppliesAutoTraceId_WhenEnabled() + public async Task Log_AppliesAutoTraceId_WhenEnabled() { - // Arrange - var options = CreateTestOptions(); - options.AutoTraceId = true; - using var client = new LogTideClient(options); - - // Act + var (client, fake) = Create(o => o.AutoTraceId = true); client.Info("test", "Test message"); - - // Assert - can't directly check trace ID on buffered log, - // but verify no exception - Assert.NotNull(client.GetMetrics()); + await client.FlushAsync(); + Assert.NotNull(fake.LogBatches[0][0].TraceId); } [Fact] public void GetMetrics_ReturnsClone() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - - // Act + var (client, _) = Create(); var metrics1 = client.GetMetrics(); var metrics2 = client.GetMetrics(); - - // Assert - different instances Assert.NotSame(metrics1, metrics2); } [Fact] public void ResetMetrics_ClearsAllMetrics() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); - - // Act + var (client, _) = Create(); client.ResetMetrics(); var metrics = client.GetMetrics(); - - // Assert Assert.Equal(0, metrics.LogsSent); Assert.Equal(0, metrics.LogsDropped); Assert.Equal(0, metrics.Errors); @@ -178,39 +85,30 @@ public void ResetMetrics_ClearsAllMetrics() [Fact] public void GetCircuitBreakerState_ReturnsClosedInitially() { - // Arrange & Act - using var client = new LogTideClient(CreateTestOptions()); - - // Assert + var (client, _) = Create(); Assert.Equal(CircuitState.Closed, client.GetCircuitBreakerState()); } [Fact] - public void Error_WithException_SerializesError() + public async Task Error_WithException_SerializesError() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); + var (client, fake) = Create(); var exception = new InvalidOperationException("Test error"); - - // Act - should not throw client.Error("test", "Error occurred", exception); - - // Assert - Assert.NotNull(client.GetMetrics()); + await client.FlushAsync(); + Assert.Single(fake.LogBatches); + Assert.True(fake.LogBatches[0][0].Metadata.ContainsKey("error")); } [Fact] - public void Critical_WithException_SerializesError() + public async Task Critical_WithException_SerializesError() { - // Arrange - using var client = new LogTideClient(CreateTestOptions()); + var (client, fake) = Create(); var exception = new ApplicationException("Critical error", new InvalidOperationException("Inner error")); - - // Act - should not throw client.Critical("test", "Critical error occurred", exception); - - // Assert - Assert.NotNull(client.GetMetrics()); + await client.FlushAsync(); + Assert.Single(fake.LogBatches); + Assert.True(fake.LogBatches[0][0].Metadata.ContainsKey("error")); } } diff --git a/tests/Models/LogEntryTests.cs b/tests/Models/LogEntryTests.cs new file mode 100644 index 0000000..1a790f1 --- /dev/null +++ b/tests/Models/LogEntryTests.cs @@ -0,0 +1,23 @@ +using Xunit; +using LogTide.SDK.Models; + +namespace LogTide.SDK.Tests.Models; + +public class LogEntryTests +{ + [Fact] + public void LogEntry_DefaultsAreCorrect() + { + var entry = new LogEntry(); + Assert.Null(entry.SpanId); + Assert.Null(entry.SessionId); + } + + [Fact] + public void ClientOptions_ParsesDsn() + { + var opts = ClientOptions.FromDsn("https://lp_mykey@api.logtide.dev"); + Assert.Equal("https://api.logtide.dev", opts.ApiUrl); + Assert.Equal("lp_mykey", opts.ApiKey); + } +} diff --git a/tests/Tracing/SpanManagerTests.cs b/tests/Tracing/SpanManagerTests.cs new file mode 100644 index 0000000..b4b4a4d --- /dev/null +++ b/tests/Tracing/SpanManagerTests.cs @@ -0,0 +1,33 @@ +using Xunit; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Tests.Tracing; + +public class SpanManagerTests +{ + [Fact] + public void StartSpan_ReturnsSpanWithCorrectFields() + { + var mgr = new SpanManager(); + var span = mgr.StartSpan("test", "trace123"); + Assert.Equal("trace123", span.TraceId); + Assert.Equal("test", span.Name); + Assert.NotEmpty(span.SpanId); + } + + [Fact] + public void FinishSpan_RemovesFromActive() + { + var mgr = new SpanManager(); + var span = mgr.StartSpan("test", "t"); + Assert.True(mgr.TryFinishSpan(span.SpanId, SpanStatus.Ok, out _)); + Assert.False(mgr.TryFinishSpan(span.SpanId, SpanStatus.Ok, out _)); + } + + [Fact] + public void TryFinishSpan_UnknownId_ReturnsFalse() + { + var mgr = new SpanManager(); + Assert.False(mgr.TryFinishSpan("nonexistent", SpanStatus.Ok, out _)); + } +} diff --git a/tests/Tracing/SpanTests.cs b/tests/Tracing/SpanTests.cs new file mode 100644 index 0000000..9df5f40 --- /dev/null +++ b/tests/Tracing/SpanTests.cs @@ -0,0 +1,45 @@ +using Xunit; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Tests.Tracing; + +public class SpanTests +{ + [Fact] + public void Span_InitialState_IsCorrect() + { + var span = new Span("abc123", "trace123", null, "HTTP GET"); + Assert.Equal("abc123", span.SpanId); + Assert.Equal("trace123", span.TraceId); + Assert.Equal("HTTP GET", span.Name); + Assert.Equal(SpanStatus.Unset, span.Status); + Assert.False(span.IsFinished); + } + + [Fact] + public void Finish_SetsEndTimeAndStatus() + { + var span = new Span("a", "b", null, "test"); + span.Finish(SpanStatus.Ok); + Assert.True(span.IsFinished); + Assert.Equal(SpanStatus.Ok, span.Status); + Assert.NotNull(span.EndTime); + } + + [Fact] + public void SetAttribute_StoresValue() + { + var span = new Span("a", "b", null, "test"); + span.SetAttribute("http.method", "GET"); + Assert.Equal("GET", span.Attributes["http.method"]); + } + + [Fact] + public void AddEvent_AppendsToList() + { + var span = new Span("a", "b", null, "test"); + span.AddEvent("exception", new Dictionary { ["message"] = "oops" }); + Assert.Single(span.Events); + Assert.Equal("exception", span.Events[0].Name); + } +} diff --git a/tests/Tracing/W3CTraceContextTests.cs b/tests/Tracing/W3CTraceContextTests.cs new file mode 100644 index 0000000..58dc027 --- /dev/null +++ b/tests/Tracing/W3CTraceContextTests.cs @@ -0,0 +1,53 @@ +using Xunit; +using LogTide.SDK.Tracing; + +namespace LogTide.SDK.Tests.Tracing; + +public class W3CTraceContextTests +{ + [Fact] + public void Parse_ValidTraceparent_ReturnsIds() + { + var result = W3CTraceContext.Parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + Assert.NotNull(result); + Assert.Equal("4bf92f3577b34da6a3ce929d0e0e4736", result.Value.TraceId); + Assert.Equal("00f067aa0ba902b7", result.Value.SpanId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("invalid")] + [InlineData("00-short-00f067aa0ba902b7-01")] + [InlineData("00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-00f067aa0ba902b7-01")] // non-hex trace ID + [InlineData("00-4bf92f3577b34da6a3ce929d0e0e4736-GGGGGGGGGGGGGGGG-01")] // uppercase/non-hex span ID + [InlineData("00-00000000000000000000000000000000-00f067aa0ba902b7-01")] // all-zeros trace ID + [InlineData("00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")] // all-zeros span ID + public void Parse_InvalidInput_ReturnsNull(string? input) + { + Assert.Null(W3CTraceContext.Parse(input)); + } + + [Fact] + public void Create_ProducesValidFormat() + { + var header = W3CTraceContext.Create("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7"); + Assert.Matches(@"^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$", header); + } + + [Fact] + public void GenerateTraceId_IsLowercaseHex32() + { + var id = W3CTraceContext.GenerateTraceId(); + Assert.Equal(32, id.Length); + Assert.Matches("^[0-9a-f]{32}$", id); + } + + [Fact] + public void GenerateSpanId_IsLowercaseHex16() + { + var id = W3CTraceContext.GenerateSpanId(); + Assert.Equal(16, id.Length); + Assert.Matches("^[0-9a-f]{16}$", id); + } +} diff --git a/tests/Transport/BatchTransportBugFixTests.cs b/tests/Transport/BatchTransportBugFixTests.cs new file mode 100644 index 0000000..ea18125 --- /dev/null +++ b/tests/Transport/BatchTransportBugFixTests.cs @@ -0,0 +1,86 @@ +using Xunit; +using LogTide.SDK.Models; +using LogTide.SDK.Tests.Helpers; +using LogTide.SDK.Transport; + +namespace LogTide.SDK.Tests.Transport; + +public class BatchTransportBugFixTests +{ + private static ClientOptions Opts(int batchSize = 100, int flushMs = 60000) => new() + { + ApiUrl = "http://localhost", ApiKey = "k", + BatchSize = batchSize, FlushIntervalMs = flushMs, + MaxRetries = 0, RetryDelayMs = 0 + }; + + [Fact] + public async Task DisposeAsync_FlushesBufferedLogs() + { + var fake = new FakeTransport(); + var transport = new BatchTransport(fake, fake, Opts()); + transport.Enqueue(new LogEntry { Service = "s", Message = "buffered" }); + + await transport.DisposeAsync(); + + Assert.Single(fake.LogBatches); + Assert.Equal("buffered", fake.LogBatches[0][0].Message); + } + + [Fact] + public void Dispose_FlushesBufferedLogs() + { + var fake = new FakeTransport(); + var transport = new BatchTransport(fake, fake, Opts()); + transport.Enqueue(new LogEntry { Service = "s", Message = "buffered" }); + + transport.Dispose(); + + Assert.Single(fake.LogBatches); + } + + [Fact] + public async Task CircuitBreakerOpen_DoesNotDoubleCountDrops() + { + var fake = new FakeTransport(); + fake.FailFirstN(100); // always fail + var opts = Opts(); + opts.MaxRetries = 0; + opts.RetryDelayMs = 0; + opts.CircuitBreakerThreshold = 1; + await using var transport = new BatchTransport(fake, fake, opts); + + // First log exhausts retries, trips circuit breaker + transport.Enqueue(new LogEntry { Service = "s", Message = "1" }); + await transport.FlushAsync(); + + // Second log should be blocked by circuit breaker + transport.Enqueue(new LogEntry { Service = "s", Message = "2" }); + await transport.FlushAsync(); + + var metrics = transport.GetMetrics(); + // Each log should be counted exactly once as dropped + Assert.Equal(2, metrics.LogsDropped); + } + + [Fact] + public async Task RetryExhaustion_RecordsFailureOnce() + { + var fake = new FakeTransport(); + fake.FailFirstN(100); + var opts = Opts(); + opts.MaxRetries = 2; + opts.RetryDelayMs = 0; + opts.CircuitBreakerThreshold = 10; // high threshold so CB doesn't trip + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry { Service = "s", Message = "m" }); + await transport.FlushAsync(); + + var metrics = transport.GetMetrics(); + // Should have 3 errors (initial + 2 retries), 2 retries, 1 dropped + Assert.Equal(3, metrics.Errors); + Assert.Equal(2, metrics.Retries); + Assert.Equal(1, metrics.LogsDropped); + } +} diff --git a/tests/Transport/BatchTransportTests.cs b/tests/Transport/BatchTransportTests.cs new file mode 100644 index 0000000..2500859 --- /dev/null +++ b/tests/Transport/BatchTransportTests.cs @@ -0,0 +1,89 @@ +using Xunit; +using LogTide.SDK.Exceptions; +using LogTide.SDK.Models; +using LogTide.SDK.Tests.Helpers; +using LogTide.SDK.Transport; + +namespace LogTide.SDK.Tests.Transport; + +public class BatchTransportTests +{ + private static ClientOptions Opts(int batchSize = 100, int flushMs = 60000) => new() + { + ApiUrl = "http://localhost", ApiKey = "k", + BatchSize = batchSize, FlushIntervalMs = flushMs, + MaxRetries = 0, RetryDelayMs = 0 + }; + + [Fact] + public async Task Enqueue_TriggersBatchFlush_WhenBatchSizeReached() + { + var fake = new FakeTransport(); + await using var transport = new BatchTransport(fake, fake, Opts(batchSize: 2)); + + var e1 = new LogEntry { Service = "s", Message = "1" }; + var e2 = new LogEntry { Service = "s", Message = "2" }; + transport.Enqueue(e1); + transport.Enqueue(e2); + await transport.FlushAsync(); + + Assert.Single(fake.LogBatches); + Assert.Equal(2, fake.LogBatches[0].Count); + } + + [Fact] + public async Task FlushAsync_EmptyBuffer_DoesNothing() + { + var fake = new FakeTransport(); + await using var transport = new BatchTransport(fake, fake, Opts()); + await transport.FlushAsync(); + Assert.Empty(fake.LogBatches); + } + + [Fact] + public async Task Enqueue_DropsLog_WhenBufferFull() + { + var fake = new FakeTransport(); + var opts = Opts(); + opts.MaxBufferSize = 2; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry()); + transport.Enqueue(new LogEntry()); + Assert.Throws(() => transport.Enqueue(new LogEntry())); + } + + [Fact] + public async Task SendAsync_RetriesOnTransientFailure_ThenSucceeds() + { + var fake = new FakeTransport(); + fake.FailFirstN(2); + var opts = Opts(); + opts.MaxRetries = 3; + opts.RetryDelayMs = 0; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry { Service = "svc", Message = "m" }); + await transport.FlushAsync(); + + Assert.Single(fake.LogBatches); + Assert.Equal(2, transport.GetMetrics().Retries); + } + + [Fact] + public async Task SendAsync_ExhaustsRetries_DropsLogs() + { + var fake = new FakeTransport(); + fake.FailFirstN(10); + var opts = Opts(); + opts.MaxRetries = 2; + opts.RetryDelayMs = 0; + await using var transport = new BatchTransport(fake, fake, opts); + + transport.Enqueue(new LogEntry { Service = "svc", Message = "m" }); + await transport.FlushAsync(); + + Assert.Empty(fake.LogBatches); + Assert.Equal(1, transport.GetMetrics().LogsDropped); + } +}