Skip to content

Commit 888db4c

Browse files
authored
Merge pull request #811 from Chris0Jeky/feature/error-tracking-analytics
OBS-02: Error tracking and product analytics foundation
2 parents cb40634 + bf60d0f commit 888db4c

25 files changed

Lines changed: 2788 additions & 5 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Taskdeck.Application.Services;
4+
5+
namespace Taskdeck.Api.Controllers;
6+
7+
/// <summary>
8+
/// Endpoints for opt-in product telemetry event recording and client configuration.
9+
/// All telemetry is disabled by default and requires explicit opt-in.
10+
/// </summary>
11+
[ApiController]
12+
[Route("api/telemetry")]
13+
[Authorize]
14+
public class TelemetryController : ControllerBase
15+
{
16+
private readonly ITelemetryEventService _telemetryEventService;
17+
private readonly SentrySettings _sentrySettings;
18+
private readonly AnalyticsSettings _analyticsSettings;
19+
private readonly TelemetrySettings _telemetrySettings;
20+
21+
public TelemetryController(
22+
ITelemetryEventService telemetryEventService,
23+
SentrySettings sentrySettings,
24+
AnalyticsSettings analyticsSettings,
25+
TelemetrySettings telemetrySettings)
26+
{
27+
_telemetryEventService = telemetryEventService;
28+
_sentrySettings = sentrySettings;
29+
_analyticsSettings = analyticsSettings;
30+
_telemetrySettings = telemetrySettings;
31+
}
32+
33+
/// <summary>
34+
/// Returns client-side telemetry configuration. The frontend uses this to
35+
/// determine which integrations are available and how to initialize them.
36+
/// DSNs and script URLs are only returned when the corresponding integration
37+
/// is enabled. No secrets or API keys are exposed.
38+
/// </summary>
39+
[HttpGet("config")]
40+
[AllowAnonymous]
41+
public IActionResult GetConfig()
42+
{
43+
return Ok(new ClientTelemetryConfigResponse
44+
{
45+
Sentry = new SentryClientConfig
46+
{
47+
Enabled = _sentrySettings.Enabled,
48+
Dsn = _sentrySettings.Enabled ? _sentrySettings.Dsn : string.Empty,
49+
Environment = _sentrySettings.Environment,
50+
TracesSampleRate = _sentrySettings.TracesSampleRate,
51+
},
52+
Analytics = new AnalyticsClientConfig
53+
{
54+
Enabled = _analyticsSettings.Enabled,
55+
Provider = _analyticsSettings.Enabled ? _analyticsSettings.Provider : string.Empty,
56+
ScriptUrl = _analyticsSettings.Enabled ? _analyticsSettings.ScriptUrl : string.Empty,
57+
SiteId = _analyticsSettings.Enabled ? _analyticsSettings.SiteId : string.Empty,
58+
},
59+
Telemetry = new TelemetryClientConfig
60+
{
61+
Enabled = _telemetrySettings.Enabled,
62+
},
63+
});
64+
}
65+
66+
/// <summary>
67+
/// Records a batch of product telemetry events. Requires authentication.
68+
/// Events are validated against the taxonomy naming convention and rejected
69+
/// if telemetry is disabled on the server.
70+
/// </summary>
71+
[HttpPost("events")]
72+
public IActionResult RecordEvents([FromBody] TelemetryBatchRequest? request)
73+
{
74+
if (!_telemetryEventService.IsEnabled)
75+
{
76+
return Ok(new { recorded = 0, message = "Telemetry is disabled on this server." });
77+
}
78+
79+
if (request == null || request.Events == null || request.Events.Count == 0)
80+
{
81+
return BadRequest(new { error = "No events provided." });
82+
}
83+
84+
var recorded = _telemetryEventService.RecordEvents(request.Events);
85+
return Ok(new { recorded });
86+
}
87+
}
88+
89+
public sealed class ClientTelemetryConfigResponse
90+
{
91+
public SentryClientConfig Sentry { get; set; } = new();
92+
public AnalyticsClientConfig Analytics { get; set; } = new();
93+
public TelemetryClientConfig Telemetry { get; set; } = new();
94+
}
95+
96+
public sealed class SentryClientConfig
97+
{
98+
public bool Enabled { get; set; }
99+
public string Dsn { get; set; } = string.Empty;
100+
public string Environment { get; set; } = string.Empty;
101+
public double TracesSampleRate { get; set; }
102+
}
103+
104+
public sealed class AnalyticsClientConfig
105+
{
106+
public bool Enabled { get; set; }
107+
public string Provider { get; set; } = string.Empty;
108+
public string ScriptUrl { get; set; } = string.Empty;
109+
public string SiteId { get; set; } = string.Empty;
110+
}
111+
112+
public sealed class TelemetryClientConfig
113+
{
114+
public bool Enabled { get; set; }
115+
}
116+
117+
public sealed class TelemetryBatchRequest
118+
{
119+
public List<TelemetryEvent> Events { get; set; } = new();
120+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using System.Text.RegularExpressions;
2+
using Taskdeck.Application.Services;
3+
4+
namespace Taskdeck.Api.Extensions;
5+
6+
public static class SentryRegistration
7+
{
8+
// Patterns for PII that may leak through exception messages
9+
private static readonly Regex EmailPattern = new(
10+
@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}",
11+
RegexOptions.Compiled);
12+
13+
private static readonly Regex JwtPattern = new(
14+
@"eyJ[a-zA-Z0-9_\-]+\.eyJ[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+",
15+
RegexOptions.Compiled);
16+
17+
/// <summary>
18+
/// Adds Sentry error tracking when enabled via configuration.
19+
/// Disabled by default — requires Sentry:Enabled=true and a valid DSN.
20+
/// PII is never sent (SendDefaultPii is always forced to false).
21+
/// </summary>
22+
public static WebApplicationBuilder AddTaskdeckSentry(
23+
this WebApplicationBuilder builder,
24+
SentrySettings sentrySettings)
25+
{
26+
if (!sentrySettings.Enabled)
27+
{
28+
return builder;
29+
}
30+
31+
if (string.IsNullOrWhiteSpace(sentrySettings.Dsn))
32+
{
33+
return builder;
34+
}
35+
36+
builder.WebHost.UseSentry(options =>
37+
{
38+
options.Dsn = sentrySettings.Dsn;
39+
options.Environment = sentrySettings.Environment;
40+
options.TracesSampleRate = sentrySettings.TracesSampleRate;
41+
42+
// Hard privacy guardrail: never send PII regardless of config.
43+
// This prevents usernames, emails, IP addresses, and request
44+
// bodies from being included in Sentry events.
45+
options.SendDefaultPii = false;
46+
47+
// Prevent hostname leakage
48+
options.ServerName = string.Empty;
49+
50+
// Scrub PII from exception messages and event data before sending.
51+
// Exception messages may contain emails, JWT tokens, or usernames
52+
// that were interpolated into error strings.
53+
options.SetBeforeSend((sentryEvent, _) =>
54+
{
55+
if (sentryEvent.Message?.Formatted != null)
56+
{
57+
sentryEvent.Message = new Sentry.SentryMessage
58+
{
59+
Formatted = ScrubPii(sentryEvent.Message.Formatted)
60+
};
61+
}
62+
63+
// Scrub PII from captured exception values. The Sentry SDK copies
64+
// exception messages into SentryException objects with a Value property.
65+
if (sentryEvent.SentryExceptions != null)
66+
{
67+
foreach (var sentryException in sentryEvent.SentryExceptions)
68+
{
69+
if (!string.IsNullOrEmpty(sentryException.Value))
70+
{
71+
sentryException.Value = ScrubPii(sentryException.Value);
72+
}
73+
}
74+
}
75+
76+
return sentryEvent;
77+
});
78+
79+
// Strip sensitive data from breadcrumbs. Sentry breadcrumb Data
80+
// is read-only, so we filter by dropping HTTP breadcrumbs that
81+
// carry authorization or cookie information.
82+
options.SetBeforeBreadcrumb(breadcrumb =>
83+
{
84+
if (breadcrumb.Category == "http" && breadcrumb.Data != null)
85+
{
86+
var sensitiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
87+
{
88+
"Authorization", "Cookie", "Set-Cookie", "X-Api-Key"
89+
};
90+
foreach (var key in breadcrumb.Data.Keys)
91+
{
92+
if (sensitiveKeys.Contains(key))
93+
{
94+
// Data contains sensitive headers — drop entire breadcrumb
95+
// to prevent PII leakage. The breadcrumb is replaced with
96+
// a sanitized version without data.
97+
return new Sentry.Breadcrumb(
98+
message: breadcrumb.Message ?? string.Empty,
99+
type: breadcrumb.Type ?? string.Empty,
100+
data: null,
101+
category: breadcrumb.Category,
102+
level: breadcrumb.Level);
103+
}
104+
}
105+
}
106+
107+
return breadcrumb;
108+
});
109+
});
110+
111+
return builder;
112+
}
113+
114+
/// <summary>
115+
/// Scrubs known PII patterns (emails, JWTs) from a string.
116+
/// </summary>
117+
internal static string ScrubPii(string input)
118+
{
119+
if (string.IsNullOrEmpty(input)) return input;
120+
121+
var result = EmailPattern.Replace(input, "[email-redacted]");
122+
result = JwtPattern.Replace(result, "[jwt-redacted]");
123+
return result;
124+
}
125+
126+
}

backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ public static IServiceCollection AddTaskdeckSettings(
1111
out ObservabilitySettings observabilitySettings,
1212
out RateLimitingSettings rateLimitingSettings,
1313
out JwtSettings jwtSettings,
14-
out GitHubOAuthSettings gitHubOAuthSettings)
14+
out GitHubOAuthSettings gitHubOAuthSettings,
15+
out SentrySettings sentrySettings,
16+
out TelemetrySettings telemetrySettings,
17+
out AnalyticsSettings analyticsSettings)
1518
{
1619
observabilitySettings = configuration
1720
.GetSection("Observability")
@@ -49,6 +52,24 @@ public static IServiceCollection AddTaskdeckSettings(
4952
sandboxSettings.Enabled = sandboxSettings.Enabled && environment.IsDevelopment();
5053
services.AddSingleton(sandboxSettings);
5154

55+
sentrySettings = configuration
56+
.GetSection("Sentry")
57+
.Get<SentrySettings>() ?? new SentrySettings();
58+
services.AddSingleton(sentrySettings);
59+
60+
telemetrySettings = configuration
61+
.GetSection("Telemetry")
62+
.Get<TelemetrySettings>() ?? new TelemetrySettings();
63+
services.AddSingleton(telemetrySettings);
64+
65+
analyticsSettings = configuration
66+
.GetSection("Analytics")
67+
.Get<AnalyticsSettings>() ?? new AnalyticsSettings();
68+
services.AddSingleton(analyticsSettings);
69+
70+
// Register telemetry event service (opt-in guard is internal to the service)
71+
services.AddSingleton<ITelemetryEventService, TelemetryEventService>();
72+
5273
return services;
5374
}
5475
}

backend/src/Taskdeck.Api/Program.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,17 @@
156156
}
157157
});
158158

159-
// Bind configuration settings (observability, rate limiting, security headers, JWT, etc.)
159+
// Bind configuration settings (observability, rate limiting, security headers, JWT, Sentry, telemetry, analytics)
160160
builder.Services.AddTaskdeckSettings(
161161
builder.Configuration,
162162
builder.Environment,
163163
out var observabilitySettings,
164164
out var rateLimitingSettings,
165165
out var jwtSettings,
166-
out var gitHubOAuthSettings);
166+
out var gitHubOAuthSettings,
167+
out var sentrySettings,
168+
out _, // telemetrySettings — registered in DI by AddTaskdeckSettings
169+
out _); // analyticsSettings — registered in DI by AddTaskdeckSettings
167170

168171
// Add Infrastructure (DbContext, Repositories)
169172
builder.Services.AddInfrastructure(builder.Configuration);
@@ -196,6 +199,9 @@
196199
// Add OpenTelemetry observability
197200
builder.Services.AddTaskdeckObservability(observabilitySettings);
198201

202+
// Add Sentry error tracking (config-gated, disabled by default)
203+
builder.AddTaskdeckSentry(sentrySettings);
204+
199205
// Add worker services (LLM queue, proposal housekeeping, outbound webhooks)
200206
builder.Services.AddTaskdeckWorkers(builder.Configuration, builder.Environment);
201207

backend/src/Taskdeck.Api/Taskdeck.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.1" />
3636
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
3737
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
38+
<PackageReference Include="Sentry.AspNetCore" Version="5.6.0" />
3839
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
3940
</ItemGroup>
4041

backend/src/Taskdeck.Api/appsettings.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@
3838
"EnableConsoleExporter": false,
3939
"MetricExportIntervalSeconds": 30
4040
},
41+
"Sentry": {
42+
"Enabled": false,
43+
"Dsn": "",
44+
"Environment": "production",
45+
"TracesSampleRate": 0.1,
46+
"SendDefaultPii": false
47+
},
48+
"Telemetry": {
49+
"Enabled": false,
50+
"MaxBatchSize": 100
51+
},
52+
"Analytics": {
53+
"Enabled": false,
54+
"Provider": "",
55+
"ScriptUrl": "",
56+
"SiteId": ""
57+
},
4158
"RateLimiting": {
4259
"Enabled": true,
4360
"AuthPerIp": {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Taskdeck.Application.Services;
2+
3+
/// <summary>
4+
/// Configuration for self-hosted web analytics (Plausible or Umami).
5+
/// Disabled by default — the frontend reads these settings from a config endpoint
6+
/// and injects the analytics script only when configured.
7+
/// </summary>
8+
public sealed class AnalyticsSettings
9+
{
10+
/// <summary>
11+
/// Master switch. Default: false (opt-in only).
12+
/// </summary>
13+
public bool Enabled { get; set; }
14+
15+
/// <summary>
16+
/// Analytics provider: "plausible" or "umami". Case-insensitive.
17+
/// </summary>
18+
public string Provider { get; set; } = string.Empty;
19+
20+
/// <summary>
21+
/// Full URL to the analytics script (e.g. "https://plausible.example.com/js/script.js").
22+
/// </summary>
23+
public string ScriptUrl { get; set; } = string.Empty;
24+
25+
/// <summary>
26+
/// Site identifier / website ID used by the analytics provider.
27+
/// </summary>
28+
public string SiteId { get; set; } = string.Empty;
29+
}

0 commit comments

Comments
 (0)