Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1917b9b
Add SentrySettings configuration class for error tracking
Chris0Jeky Apr 9, 2026
b0fade6
Add AnalyticsSettings for self-hosted web analytics config
Chris0Jeky Apr 9, 2026
9de43a8
Add telemetry event model, settings, and service interface
Chris0Jeky Apr 9, 2026
9ef72f6
Add TelemetryEventService with opt-in guard and event validation
Chris0Jeky Apr 9, 2026
24893e1
Add Sentry SDK package and config-gated registration extension
Chris0Jeky Apr 9, 2026
afea1eb
Register Sentry, telemetry, and analytics settings in DI
Chris0Jeky Apr 9, 2026
d133017
Wire Sentry and telemetry settings into application startup
Chris0Jeky Apr 9, 2026
539462d
Add Sentry, Telemetry, and Analytics config sections (all disabled by…
Chris0Jeky Apr 9, 2026
d5743d0
Add telemetry API controller with config and event endpoints
Chris0Jeky Apr 9, 2026
45575dc
Fix Sentry registration to use correct SDK API and null-safe breadcru…
Chris0Jeky Apr 9, 2026
77c9123
Add TelemetryEventService unit tests with validation coverage
Chris0Jeky Apr 9, 2026
fdaa2a7
Add API integration tests for telemetry config and endpoints
Chris0Jeky Apr 9, 2026
1681ab6
Add telemetry API client for config and event endpoints
Chris0Jeky Apr 9, 2026
7353e16
Add telemetry store with opt-in consent, event buffering, and periodi…
Chris0Jeky Apr 9, 2026
5e83325
Add analytics script injection composable for Plausible/Umami
Chris0Jeky Apr 9, 2026
685ce46
Add telemetry consent UI to settings page with privacy disclosure
Chris0Jeky Apr 9, 2026
b7aa139
Initialize telemetry store on app startup (non-blocking, opt-in)
Chris0Jeky Apr 9, 2026
d9bfe5d
Switch telemetry API to shared http client for auth consistency
Chris0Jeky Apr 9, 2026
385edd5
Add frontend tests for telemetry store and API client
Chris0Jeky Apr 9, 2026
4f6407a
Add observability setup guide documenting Sentry, analytics, and tele…
Chris0Jeky Apr 9, 2026
0596bb6
Fix TelemetryController to satisfy architecture boundary tests
Chris0Jeky Apr 9, 2026
d6c6d82
Add Sentry PII scrubbing: BeforeSend handler, case-insensitive headers
Chris0Jeky Apr 9, 2026
bda148f
Add property key allowlist and size limits to TelemetryEventService
Chris0Jeky Apr 9, 2026
47322dc
Validate analytics script URL is HTTPS before DOM injection
Chris0Jeky Apr 9, 2026
68815f7
Fix broken PostEvents auth test: remove unused unauthClient variable
Chris0Jeky Apr 9, 2026
7c52d71
Respect DNT/GPC browser privacy signals in telemetry consent
Chris0Jeky Apr 9, 2026
0c03a8f
Fix telemetry privacy typecheck guard
Chris0Jeky Apr 9, 2026
3891fb3
Address PR #811 review comments
Chris0Jeky Apr 12, 2026
7be3b44
Add useAnalyticsScript composable tests to meet coverage threshold
Chris0Jeky Apr 12, 2026
bf60d0f
Merge main into feature/error-tracking-analytics
Chris0Jeky Apr 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/TelemetryController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Controllers;

/// <summary>
/// Endpoints for opt-in product telemetry event recording and client configuration.
/// All telemetry is disabled by default and requires explicit opt-in.
/// </summary>
[ApiController]
[Route("api/telemetry")]
[Authorize]
public class TelemetryController : ControllerBase
{
private readonly ITelemetryEventService _telemetryEventService;
private readonly SentrySettings _sentrySettings;
private readonly AnalyticsSettings _analyticsSettings;
private readonly TelemetrySettings _telemetrySettings;

public TelemetryController(
ITelemetryEventService telemetryEventService,
SentrySettings sentrySettings,
AnalyticsSettings analyticsSettings,
TelemetrySettings telemetrySettings)
{
_telemetryEventService = telemetryEventService;
_sentrySettings = sentrySettings;
_analyticsSettings = analyticsSettings;
_telemetrySettings = telemetrySettings;
}

/// <summary>
/// Returns client-side telemetry configuration. The frontend uses this to
/// determine which integrations are available and how to initialize them.
/// DSNs and script URLs are only returned when the corresponding integration
/// is enabled. No secrets or API keys are exposed.
/// </summary>
[HttpGet("config")]
[AllowAnonymous]
public IActionResult GetConfig()
{
return Ok(new ClientTelemetryConfigResponse
{
Sentry = new SentryClientConfig
{
Enabled = _sentrySettings.Enabled,
Dsn = _sentrySettings.Enabled ? _sentrySettings.Dsn : string.Empty,
Environment = _sentrySettings.Environment,
TracesSampleRate = _sentrySettings.TracesSampleRate,
},
Analytics = new AnalyticsClientConfig
{
Enabled = _analyticsSettings.Enabled,
Provider = _analyticsSettings.Enabled ? _analyticsSettings.Provider : string.Empty,
ScriptUrl = _analyticsSettings.Enabled ? _analyticsSettings.ScriptUrl : string.Empty,
SiteId = _analyticsSettings.Enabled ? _analyticsSettings.SiteId : string.Empty,
},
Telemetry = new TelemetryClientConfig
{
Enabled = _telemetrySettings.Enabled,
},
});
}

/// <summary>
/// Records a batch of product telemetry events. Requires authentication.
/// Events are validated against the taxonomy naming convention and rejected
/// if telemetry is disabled on the server.
/// </summary>
[HttpPost("events")]
public IActionResult RecordEvents([FromBody] TelemetryBatchRequest? request)
{
if (!_telemetryEventService.IsEnabled)
Comment on lines +71 to +74
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecordEvents doesn't defensively handle a null request body (e.g. client sends JSON null), which would throw when accessing request.Events. With [ApiController] you often get an automatic 400, but it's safer to accept TelemetryBatchRequest? and explicitly return BadRequest when request is null.

Copilot uses AI. Check for mistakes.
{
return Ok(new { recorded = 0, message = "Telemetry is disabled on this server." });
}

if (request == null || request.Events == null || request.Events.Count == 0)
{
return BadRequest(new { error = "No events provided." });
}

var recorded = _telemetryEventService.RecordEvents(request.Events);
return Ok(new { recorded });
}
Comment on lines +74 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The RecordEvents action method uses anonymous types for its responses, which leads to an inconsistency between the success path and the disabled path. The success path returns { recorded }, while the disabled path returns { recorded, message }.

To improve type safety and consistency, it's better to define and use a specific response DTO for this endpoint. This aligns with the TelemetryBatchResponse interface already defined on the frontend.

        if (!_telemetryEventService.IsEnabled)
        {
            return Ok(new TelemetryBatchResponse { Recorded = 0, Message = "Telemetry is disabled on this server." });
        }

        if (request.Events == null || request.Events.Count == 0)
        {
            return BadRequest(new { error = "No events provided." });
        }

        var recorded = _telemetryEventService.RecordEvents(request.Events);
        return Ok(new TelemetryBatchResponse { Recorded = recorded });
    }
}

public sealed class ClientTelemetryConfigResponse
{
    public SentryClientConfig Sentry { get; set; } = new();
    public AnalyticsClientConfig Analytics { get; set; } = new();
    public TelemetryClientConfig Telemetry { get; set; } = new();
}

public sealed class SentryClientConfig
{
    public bool Enabled { get; set; }
    public string Dsn { get; set; } = string.Empty;
    public string Environment { get; set; } = string.Empty;
    public double TracesSampleRate { get; set; }
}

public sealed class AnalyticsClientConfig
{
    public bool Enabled { get; set; }
    public string Provider { get; set; } = string.Empty;
    public string ScriptUrl { get; set; } = string.Empty;
    public string SiteId { get; set; } = string.Empty;
}

public sealed class TelemetryClientConfig
{
    public bool Enabled { get; set; }
}

public sealed class TelemetryBatchRequest
{
    public List<TelemetryEvent> Events { get; set; } = new();
}

public sealed class TelemetryBatchResponse
{
    public int Recorded { get; set; }
    public string? Message { get; set; }
}

}

public sealed class ClientTelemetryConfigResponse
{
public SentryClientConfig Sentry { get; set; } = new();
public AnalyticsClientConfig Analytics { get; set; } = new();
public TelemetryClientConfig Telemetry { get; set; } = new();
}

public sealed class SentryClientConfig
{
public bool Enabled { get; set; }
public string Dsn { get; set; } = string.Empty;
public string Environment { get; set; } = string.Empty;
public double TracesSampleRate { get; set; }
}

public sealed class AnalyticsClientConfig
{
public bool Enabled { get; set; }
public string Provider { get; set; } = string.Empty;
public string ScriptUrl { get; set; } = string.Empty;
public string SiteId { get; set; } = string.Empty;
}

public sealed class TelemetryClientConfig
{
public bool Enabled { get; set; }
}

public sealed class TelemetryBatchRequest
{
public List<TelemetryEvent> Events { get; set; } = new();
}
126 changes: 126 additions & 0 deletions backend/src/Taskdeck.Api/Extensions/SentryRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Text.RegularExpressions;
using Taskdeck.Application.Services;

namespace Taskdeck.Api.Extensions;

public static class SentryRegistration
{
// Patterns for PII that may leak through exception messages
private static readonly Regex EmailPattern = new(
@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}",
RegexOptions.Compiled);

private static readonly Regex JwtPattern = new(
@"eyJ[a-zA-Z0-9_\-]+\.eyJ[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+",
RegexOptions.Compiled);

/// <summary>
/// Adds Sentry error tracking when enabled via configuration.
/// Disabled by default — requires Sentry:Enabled=true and a valid DSN.
/// PII is never sent (SendDefaultPii is always forced to false).
/// </summary>
public static WebApplicationBuilder AddTaskdeckSentry(
this WebApplicationBuilder builder,
SentrySettings sentrySettings)
{
if (!sentrySettings.Enabled)
{
return builder;
}

if (string.IsNullOrWhiteSpace(sentrySettings.Dsn))
{
return builder;
}

builder.WebHost.UseSentry(options =>
{
options.Dsn = sentrySettings.Dsn;
options.Environment = sentrySettings.Environment;
options.TracesSampleRate = sentrySettings.TracesSampleRate;

// Hard privacy guardrail: never send PII regardless of config.
// This prevents usernames, emails, IP addresses, and request
// bodies from being included in Sentry events.
options.SendDefaultPii = false;

// Prevent hostname leakage
options.ServerName = string.Empty;

// Scrub PII from exception messages and event data before sending.
// Exception messages may contain emails, JWT tokens, or usernames
// that were interpolated into error strings.
options.SetBeforeSend((sentryEvent, _) =>
{
if (sentryEvent.Message?.Formatted != null)
{
sentryEvent.Message = new Sentry.SentryMessage
{
Formatted = ScrubPii(sentryEvent.Message.Formatted)
};
}

// Scrub PII from captured exception values. The Sentry SDK copies
// exception messages into SentryException objects with a Value property.
if (sentryEvent.SentryExceptions != null)
{
foreach (var sentryException in sentryEvent.SentryExceptions)
{
if (!string.IsNullOrEmpty(sentryException.Value))
{
sentryException.Value = ScrubPii(sentryException.Value);
}
}
}

return sentryEvent;
});

// Strip sensitive data from breadcrumbs. Sentry breadcrumb Data
// is read-only, so we filter by dropping HTTP breadcrumbs that
// carry authorization or cookie information.
options.SetBeforeBreadcrumb(breadcrumb =>
{
if (breadcrumb.Category == "http" && breadcrumb.Data != null)
{
var sensitiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Authorization", "Cookie", "Set-Cookie", "X-Api-Key"
};
foreach (var key in breadcrumb.Data.Keys)
{
if (sensitiveKeys.Contains(key))
{
// Data contains sensitive headers — drop entire breadcrumb
// to prevent PII leakage. The breadcrumb is replaced with
// a sanitized version without data.
return new Sentry.Breadcrumb(
message: breadcrumb.Message ?? string.Empty,
type: breadcrumb.Type ?? string.Empty,
data: null,
category: breadcrumb.Category,
level: breadcrumb.Level);
}
}
Comment on lines +84 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for stripping sensitive headers from Sentry breadcrumbs is case-sensitive and could miss headers like AUTHORIZATION or Cookie with different casing. HTTP headers are case-insensitive by specification.

To ensure all sensitive headers are properly stripped, I recommend using a case-insensitive check. A static readonly HashSet<string> with StringComparer.OrdinalIgnoreCase is a more robust and performant way to handle this.

                if (breadcrumb.Category == "http" && breadcrumb.Data != null)
                {
                    var sensitiveKeys = new HashSet<string>(new[] { "Authorization", "Cookie" }, StringComparer.OrdinalIgnoreCase);
                    if (breadcrumb.Data.Keys.Any(k => sensitiveKeys.Contains(k)))
                    {
                        // Data contains sensitive headers — drop entire breadcrumb
                        // to prevent PII leakage. The breadcrumb is replaced with
                        // a sanitized version without data.
                        return new Sentry.Breadcrumb(
                            message: breadcrumb.Message ?? string.Empty,
                            type: breadcrumb.Type ?? string.Empty,
                            data: null,
                            category: breadcrumb.Category,
                            level: breadcrumb.Level);
                    }
                }

}

return breadcrumb;
});
});

return builder;
}

/// <summary>
/// Scrubs known PII patterns (emails, JWTs) from a string.
/// </summary>
internal static string ScrubPii(string input)
{
if (string.IsNullOrEmpty(input)) return input;

var result = EmailPattern.Replace(input, "[email-redacted]");
result = JwtPattern.Replace(result, "[jwt-redacted]");
return result;
}

}
23 changes: 22 additions & 1 deletion backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ public static IServiceCollection AddTaskdeckSettings(
out ObservabilitySettings observabilitySettings,
out RateLimitingSettings rateLimitingSettings,
out JwtSettings jwtSettings,
out GitHubOAuthSettings gitHubOAuthSettings)
out GitHubOAuthSettings gitHubOAuthSettings,
out SentrySettings sentrySettings,
out TelemetrySettings telemetrySettings,
out AnalyticsSettings analyticsSettings)
{
observabilitySettings = configuration
.GetSection("Observability")
Expand Down Expand Up @@ -49,6 +52,24 @@ public static IServiceCollection AddTaskdeckSettings(
sandboxSettings.Enabled = sandboxSettings.Enabled && environment.IsDevelopment();
services.AddSingleton(sandboxSettings);

sentrySettings = configuration
.GetSection("Sentry")
.Get<SentrySettings>() ?? new SentrySettings();
services.AddSingleton(sentrySettings);

telemetrySettings = configuration
.GetSection("Telemetry")
.Get<TelemetrySettings>() ?? new TelemetrySettings();
services.AddSingleton(telemetrySettings);

analyticsSettings = configuration
.GetSection("Analytics")
.Get<AnalyticsSettings>() ?? new AnalyticsSettings();
services.AddSingleton(analyticsSettings);

// Register telemetry event service (opt-in guard is internal to the service)
services.AddSingleton<ITelemetryEventService, TelemetryEventService>();

return services;
}
}
10 changes: 8 additions & 2 deletions backend/src/Taskdeck.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,17 @@
}
});

// Bind configuration settings (observability, rate limiting, security headers, JWT, etc.)
// Bind configuration settings (observability, rate limiting, security headers, JWT, Sentry, telemetry, analytics)
builder.Services.AddTaskdeckSettings(
builder.Configuration,
builder.Environment,
out var observabilitySettings,
out var rateLimitingSettings,
out var jwtSettings,
out var gitHubOAuthSettings);
out var gitHubOAuthSettings,
out var sentrySettings,
out _, // telemetrySettings — registered in DI by AddTaskdeckSettings
out _); // analyticsSettings — registered in DI by AddTaskdeckSettings

// Add Infrastructure (DbContext, Repositories)
builder.Services.AddInfrastructure(builder.Configuration);
Expand Down Expand Up @@ -196,6 +199,9 @@
// Add OpenTelemetry observability
builder.Services.AddTaskdeckObservability(observabilitySettings);

// Add Sentry error tracking (config-gated, disabled by default)
builder.AddTaskdeckSentry(sentrySettings);

// Add worker services (LLM queue, proposal housekeeping, outbound webhooks)
builder.Services.AddTaskdeckWorkers(builder.Configuration, builder.Environment);

Expand Down
1 change: 1 addition & 0 deletions backend/src/Taskdeck.Api/Taskdeck.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageReference Include="Sentry.AspNetCore" Version="5.6.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>

Expand Down
17 changes: 17 additions & 0 deletions backend/src/Taskdeck.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@
"EnableConsoleExporter": false,
"MetricExportIntervalSeconds": 30
},
"Sentry": {
"Enabled": false,
"Dsn": "",
"Environment": "production",
"TracesSampleRate": 0.1,
"SendDefaultPii": false
},
"Telemetry": {
"Enabled": false,
"MaxBatchSize": 100
},
"Analytics": {
"Enabled": false,
"Provider": "",
"ScriptUrl": "",
"SiteId": ""
},
"RateLimiting": {
"Enabled": true,
"AuthPerIp": {
Expand Down
29 changes: 29 additions & 0 deletions backend/src/Taskdeck.Application/Services/AnalyticsSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Taskdeck.Application.Services;

/// <summary>
/// Configuration for self-hosted web analytics (Plausible or Umami).
/// Disabled by default — the frontend reads these settings from a config endpoint
/// and injects the analytics script only when configured.
/// </summary>
public sealed class AnalyticsSettings
{
/// <summary>
/// Master switch. Default: false (opt-in only).
/// </summary>
public bool Enabled { get; set; }

/// <summary>
/// Analytics provider: "plausible" or "umami". Case-insensitive.
/// </summary>
public string Provider { get; set; } = string.Empty;

/// <summary>
/// Full URL to the analytics script (e.g. "https://plausible.example.com/js/script.js").
/// </summary>
public string ScriptUrl { get; set; } = string.Empty;

/// <summary>
/// Site identifier / website ID used by the analytics provider.
/// </summary>
public string SiteId { get; set; } = string.Empty;
}
Loading
Loading