diff --git a/backend/src/Taskdeck.Api/Controllers/TelemetryController.cs b/backend/src/Taskdeck.Api/Controllers/TelemetryController.cs new file mode 100644 index 000000000..1a517ef84 --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/TelemetryController.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Taskdeck.Application.Services; + +namespace Taskdeck.Api.Controllers; + +/// +/// Endpoints for opt-in product telemetry event recording and client configuration. +/// All telemetry is disabled by default and requires explicit opt-in. +/// +[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; + } + + /// + /// 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. + /// + [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, + }, + }); + } + + /// + /// 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. + /// + [HttpPost("events")] + public IActionResult RecordEvents([FromBody] TelemetryBatchRequest? request) + { + if (!_telemetryEventService.IsEnabled) + { + 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 }); + } +} + +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 Events { get; set; } = new(); +} diff --git a/backend/src/Taskdeck.Api/Extensions/SentryRegistration.cs b/backend/src/Taskdeck.Api/Extensions/SentryRegistration.cs new file mode 100644 index 000000000..9e9262b5e --- /dev/null +++ b/backend/src/Taskdeck.Api/Extensions/SentryRegistration.cs @@ -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); + + /// + /// 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). + /// + 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(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); + } + } + } + + return breadcrumb; + }); + }); + + return builder; + } + + /// + /// Scrubs known PII patterns (emails, JWTs) from a string. + /// + 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; + } + +} diff --git a/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs b/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs index 37067095a..35bcc3607 100644 --- a/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/SettingsRegistration.cs @@ -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") @@ -49,6 +52,24 @@ public static IServiceCollection AddTaskdeckSettings( sandboxSettings.Enabled = sandboxSettings.Enabled && environment.IsDevelopment(); services.AddSingleton(sandboxSettings); + sentrySettings = configuration + .GetSection("Sentry") + .Get() ?? new SentrySettings(); + services.AddSingleton(sentrySettings); + + telemetrySettings = configuration + .GetSection("Telemetry") + .Get() ?? new TelemetrySettings(); + services.AddSingleton(telemetrySettings); + + analyticsSettings = configuration + .GetSection("Analytics") + .Get() ?? new AnalyticsSettings(); + services.AddSingleton(analyticsSettings); + + // Register telemetry event service (opt-in guard is internal to the service) + services.AddSingleton(); + return services; } } diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 468b7fe6b..98085af06 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -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); @@ -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); diff --git a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj index ea805ff8d..4b651b34e 100644 --- a/backend/src/Taskdeck.Api/Taskdeck.Api.csproj +++ b/backend/src/Taskdeck.Api/Taskdeck.Api.csproj @@ -35,6 +35,7 @@ + diff --git a/backend/src/Taskdeck.Api/appsettings.json b/backend/src/Taskdeck.Api/appsettings.json index 126cd3fbe..9c798a5e1 100644 --- a/backend/src/Taskdeck.Api/appsettings.json +++ b/backend/src/Taskdeck.Api/appsettings.json @@ -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": { diff --git a/backend/src/Taskdeck.Application/Services/AnalyticsSettings.cs b/backend/src/Taskdeck.Application/Services/AnalyticsSettings.cs new file mode 100644 index 000000000..11f2ecda3 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/AnalyticsSettings.cs @@ -0,0 +1,29 @@ +namespace Taskdeck.Application.Services; + +/// +/// 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. +/// +public sealed class AnalyticsSettings +{ + /// + /// Master switch. Default: false (opt-in only). + /// + public bool Enabled { get; set; } + + /// + /// Analytics provider: "plausible" or "umami". Case-insensitive. + /// + public string Provider { get; set; } = string.Empty; + + /// + /// Full URL to the analytics script (e.g. "https://plausible.example.com/js/script.js"). + /// + public string ScriptUrl { get; set; } = string.Empty; + + /// + /// Site identifier / website ID used by the analytics provider. + /// + public string SiteId { get; set; } = string.Empty; +} diff --git a/backend/src/Taskdeck.Application/Services/ITelemetryEventService.cs b/backend/src/Taskdeck.Application/Services/ITelemetryEventService.cs new file mode 100644 index 000000000..2d3c9e51c --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ITelemetryEventService.cs @@ -0,0 +1,24 @@ +namespace Taskdeck.Application.Services; + +/// +/// Records opt-in product telemetry events aligned with the taxonomy +/// defined in docs/product/TELEMETRY_TAXONOMY.md. +/// +public interface ITelemetryEventService +{ + /// + /// Records a single telemetry event. Returns false if telemetry is disabled + /// or the event fails validation. + /// + bool RecordEvent(TelemetryEvent telemetryEvent); + + /// + /// Records a batch of telemetry events. Returns the count of successfully recorded events. + /// + int RecordEvents(IReadOnlyList events); + + /// + /// Whether telemetry recording is currently enabled. + /// + bool IsEnabled { get; } +} diff --git a/backend/src/Taskdeck.Application/Services/SentrySettings.cs b/backend/src/Taskdeck.Application/Services/SentrySettings.cs new file mode 100644 index 000000000..e0bcc1cb3 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/SentrySettings.cs @@ -0,0 +1,34 @@ +namespace Taskdeck.Application.Services; + +/// +/// Configuration for Sentry error tracking integration. +/// Disabled by default — must be explicitly enabled via configuration. +/// +public sealed class SentrySettings +{ + /// + /// Master switch for Sentry integration. Default: false (opt-in only). + /// + public bool Enabled { get; set; } + + /// + /// Sentry DSN (Data Source Name). Required when Enabled is true. + /// + public string Dsn { get; set; } = string.Empty; + + /// + /// Environment tag sent with events (e.g. "development", "production"). + /// + public string Environment { get; set; } = "production"; + + /// + /// Sample rate for performance tracing (0.0 to 1.0). Default: 0.1 (10%). + /// + public double TracesSampleRate { get; set; } = 0.1; + + /// + /// When true, Sentry SDK will send default PII (usernames, emails, IP + /// addresses, request bodies). Default: false (PII scrubbing enforced). + /// + public bool SendDefaultPii { get; set; } +} diff --git a/backend/src/Taskdeck.Application/Services/TelemetryEvent.cs b/backend/src/Taskdeck.Application/Services/TelemetryEvent.cs new file mode 100644 index 000000000..3431c7a93 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/TelemetryEvent.cs @@ -0,0 +1,43 @@ +namespace Taskdeck.Application.Services; + +/// +/// A product telemetry event following the noun.verb naming convention +/// defined in docs/product/TELEMETRY_TAXONOMY.md. +/// +public sealed class TelemetryEvent +{ + /// + /// Event name in noun.verb format (e.g. "capture.submitted", "proposal.approved"). + /// + public string Event { get; set; } = string.Empty; + + /// + /// ISO 8601 UTC timestamp of the event. + /// + public string Timestamp { get; set; } = string.Empty; + + /// + /// Anonymous session identifier, rotated on app restart. + /// + public string SessionId { get; set; } = string.Empty; + + /// + /// Current workspace mode: "guided", "workbench", or "agent". + /// + public string WorkspaceMode { get; set; } = string.Empty; + + /// + /// Semver of the running application. + /// + public string AppVersion { get; set; } = string.Empty; + + /// + /// Platform: "web", "desktop", or "cli". + /// + public string Platform { get; set; } = string.Empty; + + /// + /// Additional event-specific properties. Keys and values must not contain PII. + /// + public Dictionary? Properties { get; set; } +} diff --git a/backend/src/Taskdeck.Application/Services/TelemetryEventService.cs b/backend/src/Taskdeck.Application/Services/TelemetryEventService.cs new file mode 100644 index 000000000..67b494419 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/TelemetryEventService.cs @@ -0,0 +1,161 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; + +namespace Taskdeck.Application.Services; + +/// +/// Records opt-in product telemetry events. Events are validated against the +/// taxonomy naming convention (noun.verb, lowercase, dot-separated) and logged +/// at Information level. A future iteration may persist events or forward them +/// to an analytics backend — this version provides the guard rails and validation. +/// +public sealed class TelemetryEventService : ITelemetryEventService +{ + private static readonly Regex EventNamePattern = new( + @"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$", + RegexOptions.Compiled); + + /// + /// Allowlist of property keys that may appear in telemetry events. + /// Only keys in this set are accepted — all others are stripped. + /// This prevents callers from smuggling PII via arbitrary property keys. + /// + private static readonly HashSet AllowedPropertyKeys = new(StringComparer.Ordinal) + { + "source", "has_attachment", "duration_ms", "count", "item_count", + "workspace_mode", "error_code", "status", "method", "trigger", + "result", "step", "provider", "platform", "format", + }; + + private const int MaxPropertyCount = 10; + private const int MaxPropertyValueLength = 200; + + private readonly TelemetrySettings _settings; + private readonly ILogger _logger; + + public TelemetryEventService(TelemetrySettings settings, ILogger logger) + { + _settings = settings; + _logger = logger; + } + + public bool IsEnabled => _settings.Enabled; + + public bool RecordEvent(TelemetryEvent telemetryEvent) + { + if (!_settings.Enabled) + { + return false; + } + + if (!ValidateEvent(telemetryEvent)) + { + return false; + } + + _logger.LogInformation( + "Telemetry event recorded: {EventName} session={SessionId} mode={WorkspaceMode}", + telemetryEvent.Event, + telemetryEvent.SessionId, + telemetryEvent.WorkspaceMode); + + return true; + } + + public int RecordEvents(IReadOnlyList events) + { + if (!_settings.Enabled) + { + return 0; + } + + if (events.Count > _settings.MaxBatchSize) + { + _logger.LogWarning( + "Telemetry batch rejected: {Count} events exceeds max batch size {MaxBatchSize}", + events.Count, + _settings.MaxBatchSize); + return 0; + } + + var recorded = 0; + foreach (var evt in events) + { + // Guard against null elements in the batch + if (evt == null) + { + _logger.LogWarning("Telemetry event rejected: null element in batch"); + continue; + } + + if (RecordEvent(evt)) + { + recorded++; + } + } + + return recorded; + } + + private bool ValidateEvent(TelemetryEvent telemetryEvent) + { + if (string.IsNullOrWhiteSpace(telemetryEvent.Event)) + { + _logger.LogWarning("Telemetry event rejected: empty event name"); + return false; + } + + if (!EventNamePattern.IsMatch(telemetryEvent.Event)) + { + _logger.LogWarning( + "Telemetry event rejected: invalid event name format '{EventName}' (expected noun.verb)", + telemetryEvent.Event); + return false; + } + + if (string.IsNullOrWhiteSpace(telemetryEvent.SessionId)) + { + _logger.LogWarning("Telemetry event rejected: empty session ID for event {EventName}", telemetryEvent.Event); + return false; + } + + // Sanitize properties: strip disallowed keys, cap size, truncate values. + // This prevents PII from being smuggled via arbitrary property fields. + if (telemetryEvent.Properties != null) + { + if (telemetryEvent.Properties.Count > MaxPropertyCount) + { + _logger.LogWarning( + "Telemetry event {EventName}: properties truncated from {Count} to {Max}", + telemetryEvent.Event, telemetryEvent.Properties.Count, MaxPropertyCount); + } + + var sanitized = new Dictionary(); + foreach (var kvp in telemetryEvent.Properties) + { + if (sanitized.Count >= MaxPropertyCount) break; + + if (!AllowedPropertyKeys.Contains(kvp.Key)) + { + _logger.LogDebug( + "Telemetry event {EventName}: stripped disallowed property key '{Key}'", + telemetryEvent.Event, kvp.Key); + continue; + } + + // Truncate string values to prevent large payloads + var value = kvp.Value; + if (value is string strValue && strValue.Length > MaxPropertyValueLength) + { + value = strValue[..MaxPropertyValueLength]; + } + + sanitized[kvp.Key] = value; + } + + telemetryEvent.Properties = sanitized; + } + + return true; + } +} diff --git a/backend/src/Taskdeck.Application/Services/TelemetrySettings.cs b/backend/src/Taskdeck.Application/Services/TelemetrySettings.cs new file mode 100644 index 000000000..67d74ee9c --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/TelemetrySettings.cs @@ -0,0 +1,18 @@ +namespace Taskdeck.Application.Services; + +/// +/// Configuration for opt-in product telemetry event recording. +/// Disabled by default — events are only recorded when explicitly enabled. +/// +public sealed class TelemetrySettings +{ + /// + /// Master switch. Default: false (opt-in only). + /// + public bool Enabled { get; set; } + + /// + /// Maximum number of events accepted in a single batch request. + /// + public int MaxBatchSize { get; set; } = 100; +} diff --git a/backend/tests/Taskdeck.Api.Tests/TelemetryApiTests.cs b/backend/tests/Taskdeck.Api.Tests/TelemetryApiTests.cs new file mode 100644 index 000000000..54a219155 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/TelemetryApiTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class TelemetryApiTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + + public TelemetryApiTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetConfig_ShouldReturnOk_WithAllSectionsDisabled() + { + var response = await _client.GetAsync("/api/telemetry/config"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + + var sentry = payload.GetProperty("sentry"); + sentry.GetProperty("enabled").GetBoolean().Should().BeFalse(); + sentry.GetProperty("dsn").GetString().Should().BeEmpty(); + + var analytics = payload.GetProperty("analytics"); + analytics.GetProperty("enabled").GetBoolean().Should().BeFalse(); + + var telemetry = payload.GetProperty("telemetry"); + telemetry.GetProperty("enabled").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task GetConfig_ShouldBeAccessibleWithoutAuth() + { + // Create a client without any auth headers + var response = await _client.GetAsync("/api/telemetry/config"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task PostEvents_ShouldRequireAuth() + { + // Create an unauthenticated client to verify the endpoint rejects + // requests without auth headers. Using a fresh factory client ensures + // no auth state from other tests leaks in. + using var unauthClient = _factory.CreateClient(); + var response = await unauthClient.PostAsJsonAsync("/api/telemetry/events", new + { + events = new[] + { + new { @event = "capture.submitted", timestamp = "2026-04-09T12:00:00Z", sessionId = "abc", workspaceMode = "guided", appVersion = "0.1.0", platform = "web" } + } + }); + + // Without auth header it should return 401 + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task PostEvents_ShouldReturnZeroRecorded_WhenTelemetryDisabled() + { + await ApiTestHarness.AuthenticateAsync(_client, "telemetry_user"); + + var response = await _client.PostAsJsonAsync("/api/telemetry/events", new + { + events = new[] + { + new { @event = "capture.submitted", timestamp = "2026-04-09T12:00:00Z", sessionId = "abc", workspaceMode = "guided", appVersion = "0.1.0", platform = "web" } + } + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("recorded").GetInt32().Should().Be(0); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/TelemetryConfigurationTests.cs b/backend/tests/Taskdeck.Api.Tests/TelemetryConfigurationTests.cs new file mode 100644 index 000000000..f04940096 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/TelemetryConfigurationTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class TelemetryConfigurationTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public TelemetryConfigurationTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public void SentrySettings_ShouldBeRegisteredInDI() + { + var settings = _factory.Services.GetRequiredService(); + settings.Should().NotBeNull(); + } + + [Fact] + public void SentrySettings_ShouldBeDisabledByDefault() + { + var settings = _factory.Services.GetRequiredService(); + settings.Enabled.Should().BeFalse(); + } + + [Fact] + public void SentrySettings_SendDefaultPii_ShouldBeFalseByDefault() + { + var settings = _factory.Services.GetRequiredService(); + settings.SendDefaultPii.Should().BeFalse(); + } + + [Fact] + public void TelemetrySettings_ShouldBeRegisteredInDI() + { + var settings = _factory.Services.GetRequiredService(); + settings.Should().NotBeNull(); + } + + [Fact] + public void TelemetrySettings_ShouldBeDisabledByDefault() + { + var settings = _factory.Services.GetRequiredService(); + settings.Enabled.Should().BeFalse(); + } + + [Fact] + public void AnalyticsSettings_ShouldBeRegisteredInDI() + { + var settings = _factory.Services.GetRequiredService(); + settings.Should().NotBeNull(); + } + + [Fact] + public void AnalyticsSettings_ShouldBeDisabledByDefault() + { + var settings = _factory.Services.GetRequiredService(); + settings.Enabled.Should().BeFalse(); + } + + [Fact] + public void TelemetryEventService_ShouldBeRegisteredInDI() + { + var service = _factory.Services.GetRequiredService(); + service.Should().NotBeNull(); + } + + [Fact] + public void TelemetryEventService_ShouldBeDisabledByDefault() + { + var service = _factory.Services.GetRequiredService(); + service.IsEnabled.Should().BeFalse(); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/TelemetryEventServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/TelemetryEventServiceTests.cs new file mode 100644 index 000000000..5d3a8197d --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/TelemetryEventServiceTests.cs @@ -0,0 +1,181 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class TelemetryEventServiceTests +{ + private readonly TelemetrySettings _settings; + private readonly Mock> _loggerMock; + + public TelemetryEventServiceTests() + { + _settings = new TelemetrySettings { Enabled = true, MaxBatchSize = 100 }; + _loggerMock = new Mock>(); + } + + private TelemetryEventService CreateService() => new(_settings, _loggerMock.Object); + + private static TelemetryEvent CreateValidEvent(string eventName = "capture.submitted") => new() + { + Event = eventName, + Timestamp = "2026-04-09T12:00:00Z", + SessionId = Guid.NewGuid().ToString(), + WorkspaceMode = "guided", + AppVersion = "0.1.0", + Platform = "web", + }; + + [Fact] + public void IsEnabled_ShouldReturnTrue_WhenSettingsEnabled() + { + var service = CreateService(); + service.IsEnabled.Should().BeTrue(); + } + + [Fact] + public void IsEnabled_ShouldReturnFalse_WhenSettingsDisabled() + { + _settings.Enabled = false; + var service = CreateService(); + service.IsEnabled.Should().BeFalse(); + } + + [Fact] + public void RecordEvent_ShouldReturnFalse_WhenDisabled() + { + _settings.Enabled = false; + var service = CreateService(); + + var result = service.RecordEvent(CreateValidEvent()); + result.Should().BeFalse(); + } + + [Fact] + public void RecordEvent_ShouldReturnTrue_ForValidEvent() + { + var service = CreateService(); + + var result = service.RecordEvent(CreateValidEvent()); + result.Should().BeTrue(); + } + + [Theory] + [InlineData("capture.submitted")] + [InlineData("proposal.approved")] + [InlineData("board.loaded")] + [InlineData("auth_session.started")] + [InlineData("agent_run.completed")] + [InlineData("error.unhandled")] + [InlineData("first_run.wizard_completed")] + public void RecordEvent_ShouldAcceptValidTaxonomyNames(string eventName) + { + var service = CreateService(); + + var result = service.RecordEvent(CreateValidEvent(eventName)); + result.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + [InlineData("UPPER.CASE")] + [InlineData("three.dot.name")] + [InlineData("no-dashes.allowed")] + [InlineData(".leading_dot")] + [InlineData("trailing_dot.")] + public void RecordEvent_ShouldRejectInvalidEventNames(string eventName) + { + var service = CreateService(); + + var result = service.RecordEvent(CreateValidEvent(eventName)); + result.Should().BeFalse(); + } + + [Fact] + public void RecordEvent_ShouldRejectEmptySessionId() + { + var service = CreateService(); + var evt = CreateValidEvent(); + evt.SessionId = ""; + + var result = service.RecordEvent(evt); + result.Should().BeFalse(); + } + + [Fact] + public void RecordEvents_ShouldReturnZero_WhenDisabled() + { + _settings.Enabled = false; + var service = CreateService(); + + var result = service.RecordEvents(new List { CreateValidEvent() }); + result.Should().Be(0); + } + + [Fact] + public void RecordEvents_ShouldRecordAllValidEvents() + { + var service = CreateService(); + var events = new List + { + CreateValidEvent("capture.submitted"), + CreateValidEvent("proposal.approved"), + CreateValidEvent("board.loaded"), + }; + + var result = service.RecordEvents(events); + result.Should().Be(3); + } + + [Fact] + public void RecordEvents_ShouldRejectBatchExceedingMaxSize() + { + _settings.MaxBatchSize = 2; + var service = CreateService(); + var events = new List + { + CreateValidEvent("capture.submitted"), + CreateValidEvent("proposal.approved"), + CreateValidEvent("board.loaded"), + }; + + var result = service.RecordEvents(events); + result.Should().Be(0); + } + + [Fact] + public void RecordEvents_ShouldCountOnlyValidEvents() + { + var service = CreateService(); + var invalidEvent = CreateValidEvent("INVALID"); + var events = new List + { + CreateValidEvent("capture.submitted"), + invalidEvent, + CreateValidEvent("board.loaded"), + }; + + var result = service.RecordEvents(events); + result.Should().Be(2); + } + + [Fact] + public void RecordEvent_ShouldAcceptEventWithProperties() + { + var service = CreateService(); + var evt = CreateValidEvent(); + evt.Properties = new Dictionary + { + { "has_attachment", true }, + { "source", "manual" }, + }; + + var result = service.RecordEvent(evt); + result.Should().BeTrue(); + } +} diff --git a/backend/tests/Taskdeck.Architecture.Tests/ApiControllerBoundaryTests.cs b/backend/tests/Taskdeck.Architecture.Tests/ApiControllerBoundaryTests.cs index 88005aa64..8e7fd8aed 100644 --- a/backend/tests/Taskdeck.Architecture.Tests/ApiControllerBoundaryTests.cs +++ b/backend/tests/Taskdeck.Architecture.Tests/ApiControllerBoundaryTests.cs @@ -16,7 +16,8 @@ public class ApiControllerBoundaryTests private static readonly HashSet AllowedControllerBaseTypes = new(StringComparer.Ordinal) { "AuthController", - "HealthController" + "HealthController", + "TelemetryController" }; [Fact] diff --git a/docs/ops/OBSERVABILITY_SETUP.md b/docs/ops/OBSERVABILITY_SETUP.md new file mode 100644 index 000000000..8651ef975 --- /dev/null +++ b/docs/ops/OBSERVABILITY_SETUP.md @@ -0,0 +1,245 @@ +# Observability Setup Guide (OBS-02) + +Last Updated: 2026-04-09 +Related issue: `#549` +Depends on: `docs/ops/OBSERVABILITY_BASELINE.md` (OBS-01, #68) + +## Overview + +This guide documents how to configure error tracking, web analytics, and product telemetry for Taskdeck. All integrations are **disabled by default** and require explicit opt-in at both the server (configuration) and user (consent) level. + +## Architecture + +``` +User consent (frontend) Server config (backend) + | | + v v + telemetryStore <----> /api/telemetry/config + | + +-- Event buffering (30s flush interval) + +-- Sentry browser SDK (if enabled) + +-- Analytics script injection (if enabled) + | + v + /api/telemetry/events (authenticated, batched) + | + v + TelemetryEventService (validation + logging) +``` + +Both user consent AND server configuration must be enabled for any telemetry to flow. This dual-gate design ensures: +- Operators control what integrations are available +- Users control whether they participate + +## Backend Configuration + +All settings live in `appsettings.json` (or environment variables / `appsettings.local.json` overrides). + +### Sentry Error Tracking + +```json +{ + "Sentry": { + "Enabled": false, + "Dsn": "https://examplePublicKey@o0.ingest.sentry.io/0", + "Environment": "production", + "TracesSampleRate": 0.1, + "SendDefaultPii": false + } +} +``` + +| Setting | Default | Description | +|---|---|---| +| `Enabled` | `false` | Master switch. Set to `true` to activate Sentry. | +| `Dsn` | `""` | Sentry Data Source Name. Required when enabled. | +| `Environment` | `"production"` | Environment tag for Sentry events. | +| `TracesSampleRate` | `0.1` | Performance trace sampling rate (0.0-1.0). | +| `SendDefaultPii` | `false` | **Always forced to `false`** in code. Cannot be overridden. | + +**Privacy guardrails (enforced in code, not just config):** +- `SendDefaultPii` is always forced to `false` regardless of config value +- Authorization and Cookie headers are stripped from breadcrumbs +- No usernames, emails, or IP addresses are sent to Sentry + +Environment variable overrides: +```bash +Sentry__Enabled=true +Sentry__Dsn=https://...@sentry.io/... +Sentry__Environment=staging +``` + +### Product Telemetry + +```json +{ + "Telemetry": { + "Enabled": false, + "MaxBatchSize": 100 + } +} +``` + +| Setting | Default | Description | +|---|---|---| +| `Enabled` | `false` | Master switch for product telemetry event recording. | +| `MaxBatchSize` | `100` | Maximum events accepted per batch request. | + +When enabled, the backend validates incoming telemetry events against the taxonomy naming convention (`noun.verb`, lowercase, dot-separated) defined in `docs/product/TELEMETRY_TAXONOMY.md`. + +Environment variable overrides: +```bash +Telemetry__Enabled=true +Telemetry__MaxBatchSize=200 +``` + +### Web Analytics (Plausible/Umami) + +```json +{ + "Analytics": { + "Enabled": false, + "Provider": "plausible", + "ScriptUrl": "https://plausible.example.com/js/script.js", + "SiteId": "taskdeck.example.com" + } +} +``` + +| Setting | Default | Description | +|---|---|---| +| `Enabled` | `false` | Master switch for web analytics. | +| `Provider` | `""` | `"plausible"` or `"umami"` (case-insensitive). | +| `ScriptUrl` | `""` | Full URL to the self-hosted analytics script. | +| `SiteId` | `""` | Site identifier used by the analytics provider. | + +The frontend injects the analytics script tag only when: +1. The server has analytics enabled and configured +2. The user has given telemetry consent + +No cookies are set. No PII is collected. Analytics is cookie-free by design (both Plausible and Umami support this natively). + +Environment variable overrides: +```bash +Analytics__Enabled=true +Analytics__Provider=plausible +Analytics__ScriptUrl=https://plausible.example.com/js/script.js +Analytics__SiteId=taskdeck.example.com +``` + +## Frontend Configuration + +The frontend fetches all telemetry configuration from `/api/telemetry/config` at startup. No client-side environment variables are needed for telemetry — the backend is the single source of truth. + +### User Consent + +Telemetry consent is managed through the Settings page (`/workspace/settings/profile`). The consent state is persisted in `localStorage` under the key `taskdeck_telemetry_consent`. + +Consent controls: +- **Telemetry events**: buffered and flushed to `/api/telemetry/events` every 30 seconds +- **Sentry browser SDK**: initialized only when consent is given and server provides a DSN +- **Analytics script**: injected only when consent is given and server provides a script URL + +When consent is revoked: +- Event buffer is cleared immediately +- Flush timer is stopped +- Analytics script is removed from the DOM + +## API Endpoints + +### `GET /api/telemetry/config` + +Returns client-side telemetry configuration. **No authentication required** (the config contains no secrets — DSNs are public identifiers). + +Response: +```json +{ + "sentry": { + "enabled": false, + "dsn": "", + "environment": "production", + "tracesSampleRate": 0.1 + }, + "analytics": { + "enabled": false, + "provider": "", + "scriptUrl": "", + "siteId": "" + }, + "telemetry": { + "enabled": false + } +} +``` + +When integrations are disabled, their configuration values are returned as empty strings (not omitted) to simplify client-side logic. + +### `POST /api/telemetry/events` + +Records a batch of product telemetry events. **Requires authentication.** + +Request: +```json +{ + "events": [ + { + "event": "capture.submitted", + "timestamp": "2026-04-09T12:00:00Z", + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "workspaceMode": "guided", + "appVersion": "0.1.0", + "platform": "web", + "properties": { + "has_attachment": false, + "source": "manual" + } + } + ] +} +``` + +Response: +```json +{ + "recorded": 1 +} +``` + +## Telemetry Event Taxonomy + +All telemetry events follow the taxonomy defined in `docs/product/TELEMETRY_TAXONOMY.md`. Key rules: +- Event names use `noun.verb` format (e.g., `capture.submitted`, `proposal.approved`) +- No PII in event properties (no card content, usernames, emails, etc.) +- Only opaque UUIDs, counts, durations, and enumerated values are safe to collect + +## Deployment Checklist + +### Minimal (telemetry off) +No action needed. All integrations are disabled by default. + +### With Sentry +1. Create a Sentry project and obtain a DSN +2. Set `Sentry:Enabled=true` and `Sentry:Dsn=` +3. Verify events appear in Sentry dashboard after triggering an error + +### With Plausible +1. Deploy a self-hosted Plausible instance (or use Plausible Cloud) +2. Add your domain as a site in Plausible +3. Set `Analytics:Enabled=true`, `Analytics:Provider=plausible`, `Analytics:ScriptUrl=`, `Analytics:SiteId=` +4. Verify page views appear after a user opts in and navigates + +### With Umami +1. Deploy a self-hosted Umami instance +2. Create a website and obtain the website ID +3. Set `Analytics:Enabled=true`, `Analytics:Provider=umami`, `Analytics:ScriptUrl=`, `Analytics:SiteId=` + +### With Product Telemetry +1. Set `Telemetry:Enabled=true` +2. Events are logged at Information level — check application logs for `Telemetry event recorded:` entries +3. Future: connect to an analytics pipeline for aggregation and dashboarding + +## Related Docs + +- `docs/ops/OBSERVABILITY_BASELINE.md` — OpenTelemetry traces/metrics baseline (OBS-01) +- `docs/product/TELEMETRY_TAXONOMY.md` — canonical event naming and privacy rules +- `docs/GOLDEN_PRINCIPLES.md` — GP-06 (review-first), privacy stance diff --git a/frontend/taskdeck-web/src/api/telemetryApi.ts b/frontend/taskdeck-web/src/api/telemetryApi.ts new file mode 100644 index 000000000..f4a62ef86 --- /dev/null +++ b/frontend/taskdeck-web/src/api/telemetryApi.ts @@ -0,0 +1,52 @@ +import http from './http' + +export interface SentryClientConfig { + enabled: boolean + dsn: string + environment: string + tracesSampleRate: number +} + +export interface AnalyticsClientConfig { + enabled: boolean + provider: string + scriptUrl: string + siteId: string +} + +export interface TelemetryClientConfig { + enabled: boolean +} + +export interface ClientTelemetryConfig { + sentry: SentryClientConfig + analytics: AnalyticsClientConfig + telemetry: TelemetryClientConfig +} + +export interface TelemetryEventPayload { + event: string + timestamp: string + sessionId: string + workspaceMode: string + appVersion: string + platform: string + properties?: Record +} + +export interface TelemetryBatchResponse { + recorded: number + message?: string +} + +export const telemetryApi = { + async getConfig(): Promise { + const { data } = await http.get('/telemetry/config') + return data + }, + + async sendEvents(events: TelemetryEventPayload[]): Promise { + const { data } = await http.post('/telemetry/events', { events }) + return data + }, +} diff --git a/frontend/taskdeck-web/src/composables/useAnalyticsScript.ts b/frontend/taskdeck-web/src/composables/useAnalyticsScript.ts new file mode 100644 index 000000000..211ef3edf --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useAnalyticsScript.ts @@ -0,0 +1,181 @@ +import { watch, onUnmounted } from 'vue' +import { useTelemetryStore } from '../store/telemetryStore' + +const SCRIPT_ID = 'taskdeck-analytics-script' + +/** List of supported analytics providers */ +const SUPPORTED_PROVIDERS = ['plausible', 'umami'] + +/** Pattern for valid site IDs (alphanumeric, dots, hyphens, underscores) */ +const SITE_ID_PATTERN = /^[a-zA-Z0-9._-]+$/ + +/** + * Validates the analytics script URL is HTTPS. + */ +function isValidScriptUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' + } catch { + return false + } +} + +/** + * Validates the analytics provider is supported. + */ +function isValidProvider(provider: string): boolean { + return SUPPORTED_PROVIDERS.includes(provider.toLowerCase()) +} + +/** + * Validates the site ID format to prevent injection attacks. + */ +function isValidSiteId(siteId: string): boolean { + return !!siteId && SITE_ID_PATTERN.test(siteId) +} + +/** + * Injects a self-hosted analytics script (Plausible or Umami) when: + * 1. The user has given telemetry consent + * 2. The server has provided analytics configuration + * + * Cookie-free, no-PII analytics only. The script is removed if consent + * is revoked or the component unmounts. + */ +export function useAnalyticsScript() { + const telemetry = useTelemetryStore() + + function injectScript() { + if (document.getElementById(SCRIPT_ID)) return + + const config = telemetry.analyticsConfig + if (!config) return + + // Only allow HTTPS URLs to prevent javascript:, data:, or blob: injection + if (!isValidScriptUrl(config.scriptUrl)) { + console.warn('[Taskdeck] Analytics script URL rejected: must be HTTPS', config.scriptUrl) + return + } + + // Validate provider to prevent arbitrary attribute injection + if (!isValidProvider(config.provider)) { + console.warn('[Taskdeck] Analytics provider rejected: unsupported provider', config.provider) + return + } + + // Validate siteId to prevent injection via data attributes + if (!isValidSiteId(config.siteId)) { + console.warn('[Taskdeck] Analytics siteId rejected: invalid format', config.siteId) + return + } + + const script = document.createElement('script') + script.id = SCRIPT_ID + script.src = config.scriptUrl + script.defer = true + script.async = true + + // Provider-specific attributes + const provider = config.provider.toLowerCase() + if (provider === 'plausible') { + script.setAttribute('data-domain', config.siteId) + } else if (provider === 'umami') { + script.setAttribute('data-website-id', config.siteId) + } + + document.head.appendChild(script) + } + + function removeScript() { + const existing = document.getElementById(SCRIPT_ID) + if (existing) { + existing.remove() + } + } + + const stopWatch = watch( + () => telemetry.analyticsConfig, + (config) => { + if (config) { + injectScript() + } else { + removeScript() + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + stopWatch() + removeScript() + }) + + return { injectScript, removeScript } +} + +/** + * Initializes the analytics script watcher outside of a Vue component context. + * Called from main.ts during app bootstrap. + */ +export function initAnalyticsScriptWatcher() { + const telemetry = useTelemetryStore() + + // Helper functions duplicated here to avoid component lifecycle requirements + function injectScript() { + if (document.getElementById(SCRIPT_ID)) return + + const config = telemetry.analyticsConfig + if (!config) return + + if (!isValidScriptUrl(config.scriptUrl)) { + console.warn('[Taskdeck] Analytics script URL rejected: must be HTTPS', config.scriptUrl) + return + } + + if (!isValidProvider(config.provider)) { + console.warn('[Taskdeck] Analytics provider rejected: unsupported provider', config.provider) + return + } + + if (!isValidSiteId(config.siteId)) { + console.warn('[Taskdeck] Analytics siteId rejected: invalid format', config.siteId) + return + } + + const script = document.createElement('script') + script.id = SCRIPT_ID + script.src = config.scriptUrl + script.defer = true + script.async = true + + const provider = config.provider.toLowerCase() + if (provider === 'plausible') { + script.setAttribute('data-domain', config.siteId) + } else if (provider === 'umami') { + script.setAttribute('data-website-id', config.siteId) + } + + document.head.appendChild(script) + } + + function removeScript() { + const existing = document.getElementById(SCRIPT_ID) + if (existing) { + existing.remove() + } + } + + // Watch analyticsConfig and inject/remove script accordingly + watch( + () => telemetry.analyticsConfig, + (config) => { + if (config) { + injectScript() + } else { + removeScript() + } + }, + { immediate: true }, + ) +} diff --git a/frontend/taskdeck-web/src/main.ts b/frontend/taskdeck-web/src/main.ts index 4a4f2bf41..1e72ccbba 100644 --- a/frontend/taskdeck-web/src/main.ts +++ b/frontend/taskdeck-web/src/main.ts @@ -5,8 +5,24 @@ import App from './App.vue' import './style.css' const app = createApp(App) +const pinia = createPinia() -app.use(createPinia()) +app.use(pinia) app.use(router) app.mount('#app') + +// Initialize telemetry after mount (non-blocking, opt-in). +// This restores user consent from localStorage and fetches server config. +// No events are emitted unless the user has explicitly opted in. +import('./store/telemetryStore').then(({ useTelemetryStore }) => { + const telemetry = useTelemetryStore() + void telemetry.initialize() +}) + +// Initialize analytics script watcher after mount (non-blocking). +// This watches the telemetry store's analyticsConfig and injects/removes +// the analytics script based on user consent and server configuration. +import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => { + initAnalyticsScriptWatcher() +}) diff --git a/frontend/taskdeck-web/src/store/telemetryStore.ts b/frontend/taskdeck-web/src/store/telemetryStore.ts new file mode 100644 index 000000000..69540b495 --- /dev/null +++ b/frontend/taskdeck-web/src/store/telemetryStore.ts @@ -0,0 +1,260 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { telemetryApi } from '../api/telemetryApi' +import type { + ClientTelemetryConfig, + TelemetryEventPayload, +} from '../api/telemetryApi' + +const CONSENT_KEY = 'taskdeck_telemetry_consent' +const FLUSH_INTERVAL_MS = 30_000 // 30 seconds +const MAX_BUFFER_SIZE = 200 +type PrivacyAwareNavigator = Navigator & { + globalPrivacyControl?: boolean +} + +/** + * Checks whether the browser signals Do Not Track (DNT) or + * Global Privacy Control (GPC). When either is active, telemetry + * consent should not be auto-restored from localStorage. + */ +function browserSignalsPrivacy(): boolean { + if (typeof navigator === 'undefined') return false + // GPC has legal force under CCPA — respect it unconditionally + const browserNavigator = navigator as PrivacyAwareNavigator + if (browserNavigator.globalPrivacyControl === true) return true + // DNT is advisory but we respect it as a privacy-first product + if (browserNavigator.doNotTrack === '1') return true + return false +} + +/** + * Generates a random UUID v4 for anonymous session identification. + * This ID is rotated on every app load — it is NOT tied to user identity. + */ +function generateSessionId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID() + } + // Fallback for environments without crypto.randomUUID + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +export const useTelemetryStore = defineStore('telemetry', () => { + // ── State ────────────────────────────────────────────────────────── + + /** User has explicitly opted in to telemetry */ + const consentGiven = ref(false) + + /** Server-side telemetry config (fetched from /api/telemetry/config) */ + const serverConfig = ref(null) + + /** Whether we have fetched the server config */ + const configLoaded = ref(false) + + /** Anonymous session ID, rotated per app load */ + const sessionId = ref(generateSessionId()) + + /** Whether the browser has DNT or GPC active */ + const privacySignalActive = ref(browserSignalsPrivacy()) + + /** Buffered events waiting to be flushed */ + const eventBuffer = ref([]) + + /** Timer ID for periodic flush */ + let flushTimerId: ReturnType | null = null + + /** Guard to prevent concurrent flushes causing duplicate sends */ + let isFlushing = false + + // ── Computed ──────────────────────────────────────────────────────── + + /** Telemetry is active only when BOTH user consents AND server enables it */ + const isActive = computed( + () => consentGiven.value && !!serverConfig.value?.telemetry.enabled, + ) + + /** Sentry is available when server provides a DSN and user consents */ + const sentryAvailable = computed( + () => + consentGiven.value && + !!serverConfig.value?.sentry.enabled && + !!serverConfig.value?.sentry.dsn, + ) + + /** Analytics script config (only populated when enabled and consented) */ + const analyticsConfig = computed(() => { + if ( + !consentGiven.value || + !serverConfig.value?.analytics.enabled || + !serverConfig.value?.analytics.scriptUrl + ) { + return null + } + return serverConfig.value.analytics + }) + + // ── Actions ───────────────────────────────────────────────────────── + + /** Restore consent from localStorage. Does NOT auto-restore if DNT/GPC is active. */ + function restoreConsent() { + if (privacySignalActive.value) { + // Browser signals privacy preference — do not auto-restore consent. + // User must explicitly opt in again each session. + consentGiven.value = false + return + } + const stored = localStorage.getItem(CONSENT_KEY) + consentGiven.value = stored === 'true' + } + + /** Set user consent and persist */ + function setConsent(value: boolean) { + consentGiven.value = value + localStorage.setItem(CONSENT_KEY, String(value)) + + if (!value) { + // User revoked consent — clear buffer and stop flushing + eventBuffer.value = [] + stopFlushTimer() + } else { + startFlushTimer() + } + } + + /** Fetch server-side telemetry config */ + async function loadConfig() { + try { + serverConfig.value = await telemetryApi.getConfig() + configLoaded.value = true + } catch { + // Config fetch failure is non-fatal — telemetry simply stays disabled + serverConfig.value = null + configLoaded.value = true + } + } + + /** + * Emit a telemetry event. The event is buffered locally and flushed + * periodically. No-op if telemetry is not active. + */ + function emit( + eventName: string, + properties?: Record, + ) { + if (!isActive.value) { + return + } + + const event: TelemetryEventPayload = { + event: eventName, + timestamp: new Date().toISOString(), + sessionId: sessionId.value, + workspaceMode: 'guided', // Will be overridden by caller when available + appVersion: '0.1.0', // Will be set from build config in future + platform: 'web', + properties, + } + + eventBuffer.value.push(event) + + // Prevent unbounded memory growth + if (eventBuffer.value.length > MAX_BUFFER_SIZE) { + eventBuffer.value = eventBuffer.value.slice(-MAX_BUFFER_SIZE) + } + } + + /** Flush buffered events to the server */ + async function flush() { + if (!isActive.value || eventBuffer.value.length === 0) { + return + } + + // Prevent concurrent flushes causing duplicate sends + if (isFlushing) { + return + } + isFlushing = true + + const eventsToSend = [...eventBuffer.value] + eventBuffer.value = [] + + try { + await telemetryApi.sendEvents(eventsToSend) + } catch { + // Re-buffer events on failure (up to max size) + eventBuffer.value = [...eventsToSend, ...eventBuffer.value].slice( + -MAX_BUFFER_SIZE, + ) + } finally { + isFlushing = false + } + } + + /** Start periodic flush timer */ + function startFlushTimer() { + if (flushTimerId !== null) return + flushTimerId = setInterval(() => { + void flush() + }, FLUSH_INTERVAL_MS) + } + + /** Stop periodic flush timer */ + function stopFlushTimer() { + if (flushTimerId !== null) { + clearInterval(flushTimerId) + flushTimerId = null + } + } + + /** + * Initialize telemetry: restore consent, fetch config, start flush timer + * if consent was previously given. + */ + async function initialize() { + restoreConsent() + await loadConfig() + if (consentGiven.value) { + startFlushTimer() + } + } + + /** Clean up on unmount / page unload */ + function dispose() { + stopFlushTimer() + // Best-effort flush remaining events + if (isActive.value && eventBuffer.value.length > 0) { + void flush() + } + } + + return { + // State + consentGiven, + serverConfig, + configLoaded, + sessionId, + eventBuffer, + privacySignalActive, + + // Computed + isActive, + sentryAvailable, + analyticsConfig, + + // Actions + restoreConsent, + setConsent, + loadConfig, + emit, + flush, + initialize, + dispose, + startFlushTimer, + stopFlushTimer, + } +}) diff --git a/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts b/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts new file mode 100644 index 000000000..4a7ce96d0 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/telemetryApi.spec.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { telemetryApi } from '../../api/telemetryApi' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +describe('telemetryApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getConfig', () => { + it('should fetch telemetry config from correct endpoint', async () => { + const mockConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + } + vi.mocked(http.get).mockResolvedValue({ data: mockConfig }) + + const result = await telemetryApi.getConfig() + + expect(http.get).toHaveBeenCalledWith('/telemetry/config') + expect(result).toEqual(mockConfig) + }) + }) + + describe('sendEvents', () => { + it('should post events to correct endpoint', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { recorded: 2 } }) + + const events = [ + { + event: 'capture.submitted', + timestamp: '2026-04-09T12:00:00Z', + sessionId: 'abc', + workspaceMode: 'guided', + appVersion: '0.1.0', + platform: 'web' as const, + }, + { + event: 'board.loaded', + timestamp: '2026-04-09T12:00:01Z', + sessionId: 'abc', + workspaceMode: 'guided', + appVersion: '0.1.0', + platform: 'web' as const, + }, + ] + + const result = await telemetryApi.sendEvents(events) + + expect(http.post).toHaveBeenCalledWith('/telemetry/events', { events }) + expect(result.recorded).toBe(2) + }) + + it('should send empty events array', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { recorded: 0 } }) + + const result = await telemetryApi.sendEvents([]) + + expect(http.post).toHaveBeenCalledWith('/telemetry/events', { events: [] }) + expect(result.recorded).toBe(0) + }) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/composables/useAnalyticsScript.spec.ts b/frontend/taskdeck-web/src/tests/composables/useAnalyticsScript.spec.ts new file mode 100644 index 000000000..8785ac9af --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/useAnalyticsScript.spec.ts @@ -0,0 +1,688 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount } from '@vue/test-utils' +import { defineComponent, nextTick } from 'vue' +import { useTelemetryStore } from '../../store/telemetryStore' + +// Mock the telemetry API +vi.mock('../../api/telemetryApi', () => ({ + telemetryApi: { + getConfig: vi.fn().mockResolvedValue({ + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + }), + sendEvents: vi.fn().mockResolvedValue({ recorded: 0 }), + }, +})) + +const SCRIPT_ID = 'taskdeck-analytics-script' + +function cleanupScript() { + const script = document.getElementById(SCRIPT_ID) + if (script) { + script.remove() + } +} + +describe('useAnalyticsScript', () => { + beforeEach(() => { + setActivePinia(createPinia()) + cleanupScript() + vi.clearAllMocks() + }) + + afterEach(() => { + cleanupScript() + const store = useTelemetryStore() + store.stopFlushTimer() + }) + + describe('useAnalyticsScript composable', () => { + it('does not inject script when analyticsConfig is null', async () => { + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + }) + + it('injects script when analyticsConfig is valid', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + expect(script?.getAttribute('src')).toBe('https://plausible.example.com/js/script.js') + expect(script?.getAttribute('data-domain')).toBe('taskdeck.example.com') + }) + + it('sets data-website-id for umami provider', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'umami', + scriptUrl: 'https://umami.example.com/umami.js', + siteId: 'abc-123-def', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + expect(script?.getAttribute('data-website-id')).toBe('abc-123-def') + }) + + it('rejects non-HTTPS script URLs', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'http://insecure.example.com/script.js', + siteId: 'test.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + '[Taskdeck] Analytics script URL rejected: must be HTTPS', + 'http://insecure.example.com/script.js' + ) + + warnSpy.mockRestore() + }) + + it('rejects javascript: protocol URLs', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'javascript:alert(1)', + siteId: 'test.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + it('rejects unsupported analytics providers', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'unsupported-provider', + scriptUrl: 'https://example.com/script.js', + siteId: 'test.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + '[Taskdeck] Analytics provider rejected: unsupported provider', + 'unsupported-provider' + ) + + warnSpy.mockRestore() + }) + + it('rejects invalid siteId formats', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://example.com/script.js', + siteId: '', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalledWith( + '[Taskdeck] Analytics siteId rejected: invalid format', + '' + ) + + warnSpy.mockRestore() + }) + + it('rejects empty siteId', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://example.com/script.js', + siteId: '', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + it('does not inject duplicate scripts', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + const { injectScript } = useAnalyticsScript() + // Call inject multiple times + injectScript() + injectScript() + injectScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + const scripts = document.querySelectorAll(`#${SCRIPT_ID}`) + expect(scripts.length).toBe(1) + }) + + it('removes script when component unmounts', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + const wrapper = mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).not.toBeNull() + + wrapper.unmount() + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + }) + + it('removes script when analyticsConfig becomes null', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).not.toBeNull() + + // Revoke consent + store.setConsent(false) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + }) + + it('handles provider case insensitively', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'PLAUSIBLE', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + expect(script?.getAttribute('data-domain')).toBe('taskdeck.example.com') + }) + + it('sets defer and async attributes on script', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + const TestComponent = defineComponent({ + setup() { + useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + await nextTick() + + const script = document.getElementById(SCRIPT_ID) as HTMLScriptElement + expect(script?.defer).toBe(true) + expect(script?.async).toBe(true) + }) + + it('exposes injectScript and removeScript functions', async () => { + const { useAnalyticsScript } = await import('../../composables/useAnalyticsScript') + + let exposed: ReturnType | null = null + + const TestComponent = defineComponent({ + setup() { + exposed = useAnalyticsScript() + return {} + }, + template: '
Test
', + }) + + mount(TestComponent) + + expect(exposed).not.toBeNull() + expect(typeof exposed?.injectScript).toBe('function') + expect(typeof exposed?.removeScript).toBe('function') + }) + }) + + describe('initAnalyticsScriptWatcher', () => { + it('injects script when analyticsConfig is valid', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + }) + + it('does not inject script when config is invalid', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'invalid', + scriptUrl: 'https://example.com/script.js', + siteId: 'test.com', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + + warnSpy.mockRestore() + }) + + it('removes script when consent is revoked', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/js/script.js', + siteId: 'taskdeck.example.com', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).not.toBeNull() + + // Revoke consent + store.setConsent(false) + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + }) + + it('handles umami provider correctly', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'umami', + scriptUrl: 'https://umami.example.com/umami.js', + siteId: 'site-uuid-123', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + expect(script?.getAttribute('data-website-id')).toBe('site-uuid-123') + }) + + it('validates HTTPS requirement', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'http://insecure.com/script.js', + siteId: 'test.com', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + it('validates siteId format', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/script.js', + siteId: 'invalid<>site', + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + expect(document.getElementById(SCRIPT_ID)).toBeNull() + expect(warnSpy).toHaveBeenCalled() + + warnSpy.mockRestore() + }) + }) + + describe('valid siteId patterns', () => { + const validSiteIds = [ + 'example.com', + 'sub.example.com', + 'my-site.example.com', + 'my_site.example.com', + 'Site123', + 'a-b_c.d', + 'UUID-123-456', + ] + + for (const siteId of validSiteIds) { + it(`accepts valid siteId: ${siteId}`, async () => { + cleanupScript() + + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { + enabled: true, + provider: 'plausible', + scriptUrl: 'https://plausible.example.com/script.js', + siteId, + }, + telemetry: { enabled: false }, + } + + const { initAnalyticsScriptWatcher } = await import('../../composables/useAnalyticsScript') + initAnalyticsScriptWatcher() + await nextTick() + + const script = document.getElementById(SCRIPT_ID) + expect(script).not.toBeNull() + expect(script?.getAttribute('data-domain')).toBe(siteId) + }) + } + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts b/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts new file mode 100644 index 000000000..b319ee9e2 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/telemetryStore.spec.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useTelemetryStore } from '../../store/telemetryStore' + +// Mock the telemetry API +vi.mock('../../api/telemetryApi', () => ({ + telemetryApi: { + getConfig: vi.fn().mockResolvedValue({ + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + }), + sendEvents: vi.fn().mockResolvedValue({ recorded: 0 }), + }, +})) + +import { telemetryApi } from '../../api/telemetryApi' + +describe('telemetryStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.clearAllMocks() + }) + + afterEach(() => { + const store = useTelemetryStore() + store.stopFlushTimer() + }) + + describe('consent', () => { + it('should default to no consent', () => { + const store = useTelemetryStore() + expect(store.consentGiven).toBe(false) + }) + + it('should set consent and persist to localStorage', () => { + const store = useTelemetryStore() + store.setConsent(true) + expect(store.consentGiven).toBe(true) + expect(localStorage.getItem('taskdeck_telemetry_consent')).toBe('true') + }) + + it('should restore consent from localStorage', () => { + localStorage.setItem('taskdeck_telemetry_consent', 'true') + const store = useTelemetryStore() + store.restoreConsent() + expect(store.consentGiven).toBe(true) + }) + + it('should clear buffer when consent is revoked', () => { + const store = useTelemetryStore() + store.setConsent(true) + // Manually push an event into buffer + store.eventBuffer.push({ + event: 'test.event', + timestamp: new Date().toISOString(), + sessionId: 'abc', + workspaceMode: 'guided', + appVersion: '0.1.0', + platform: 'web', + }) + expect(store.eventBuffer.length).toBe(1) + + store.setConsent(false) + expect(store.eventBuffer.length).toBe(0) + }) + }) + + describe('isActive', () => { + it('should be false when consent not given', () => { + const store = useTelemetryStore() + expect(store.isActive).toBe(false) + }) + + it('should be false when consent given but server telemetry disabled', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + } + expect(store.isActive).toBe(false) + }) + + it('should be true when both consent and server enable telemetry', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + expect(store.isActive).toBe(true) + }) + }) + + describe('emit', () => { + it('should not buffer events when inactive', () => { + const store = useTelemetryStore() + store.emit('capture.submitted') + expect(store.eventBuffer.length).toBe(0) + }) + + it('should buffer events when active', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + store.emit('capture.submitted', { source: 'manual' }) + expect(store.eventBuffer.length).toBe(1) + expect(store.eventBuffer[0].event).toBe('capture.submitted') + expect(store.eventBuffer[0].properties).toEqual({ source: 'manual' }) + }) + + it('should cap buffer size to prevent unbounded growth', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + // Push more than MAX_BUFFER_SIZE (200) events + for (let i = 0; i < 250; i++) { + store.emit('test.event') + } + expect(store.eventBuffer.length).toBeLessThanOrEqual(200) + }) + }) + + describe('flush', () => { + it('should not flush when inactive', async () => { + const store = useTelemetryStore() + await store.flush() + expect(telemetryApi.sendEvents).not.toHaveBeenCalled() + }) + + it('should not flush when buffer is empty', async () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + await store.flush() + expect(telemetryApi.sendEvents).not.toHaveBeenCalled() + }) + + it('should send buffered events and clear buffer on success', async () => { + vi.mocked(telemetryApi.sendEvents).mockResolvedValueOnce({ recorded: 1 }) + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + store.emit('capture.submitted') + expect(store.eventBuffer.length).toBe(1) + + await store.flush() + expect(telemetryApi.sendEvents).toHaveBeenCalledTimes(1) + expect(store.eventBuffer.length).toBe(0) + }) + + it('should re-buffer events on flush failure', async () => { + vi.mocked(telemetryApi.sendEvents).mockRejectedValueOnce(new Error('network')) + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: true }, + } + store.emit('capture.submitted') + + await store.flush() + // Events should be re-buffered + expect(store.eventBuffer.length).toBe(1) + }) + }) + + describe('loadConfig', () => { + it('should fetch and store server config', async () => { + const mockConfig = { + sentry: { enabled: true, dsn: 'https://test@sentry.io/123', environment: 'prod', tracesSampleRate: 0.1 }, + analytics: { enabled: true, provider: 'plausible', scriptUrl: 'https://plausible.example.com/js/script.js', siteId: 'taskdeck.example.com' }, + telemetry: { enabled: true }, + } + vi.mocked(telemetryApi.getConfig).mockResolvedValueOnce(mockConfig) + + const store = useTelemetryStore() + await store.loadConfig() + expect(store.configLoaded).toBe(true) + expect(store.serverConfig).toEqual(mockConfig) + }) + + it('should handle config fetch failure gracefully', async () => { + vi.mocked(telemetryApi.getConfig).mockRejectedValueOnce(new Error('network')) + + const store = useTelemetryStore() + await store.loadConfig() + expect(store.configLoaded).toBe(true) + expect(store.serverConfig).toBeNull() + }) + }) + + describe('sentryAvailable', () => { + it('should be false when consent not given', () => { + const store = useTelemetryStore() + store.serverConfig = { + sentry: { enabled: true, dsn: 'https://test@sentry.io/123', environment: 'test', tracesSampleRate: 0.1 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + } + expect(store.sentryAvailable).toBe(false) + }) + + it('should be true when consent given and sentry enabled with DSN', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: true, dsn: 'https://test@sentry.io/123', environment: 'test', tracesSampleRate: 0.1 }, + analytics: { enabled: false, provider: '', scriptUrl: '', siteId: '' }, + telemetry: { enabled: false }, + } + expect(store.sentryAvailable).toBe(true) + }) + }) + + describe('analyticsConfig', () => { + it('should be null when consent not given', () => { + const store = useTelemetryStore() + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: true, provider: 'plausible', scriptUrl: 'https://example.com/script.js', siteId: 'test.com' }, + telemetry: { enabled: false }, + } + expect(store.analyticsConfig).toBeNull() + }) + + it('should return config when consent given and analytics enabled', () => { + const store = useTelemetryStore() + store.setConsent(true) + store.serverConfig = { + sentry: { enabled: false, dsn: '', environment: 'test', tracesSampleRate: 0 }, + analytics: { enabled: true, provider: 'plausible', scriptUrl: 'https://example.com/script.js', siteId: 'test.com' }, + telemetry: { enabled: false }, + } + expect(store.analyticsConfig).not.toBeNull() + expect(store.analyticsConfig?.provider).toBe('plausible') + }) + }) + + describe('sessionId', () => { + it('should generate a non-empty session ID', () => { + const store = useTelemetryStore() + expect(store.sessionId).toBeTruthy() + expect(store.sessionId.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue index f4d28e016..fd8348b7a 100644 --- a/frontend/taskdeck-web/src/views/ProfileSettingsView.vue +++ b/frontend/taskdeck-web/src/views/ProfileSettingsView.vue @@ -3,6 +3,7 @@ import { computed, ref, onMounted } from 'vue' import { useRouter, useRoute } from 'vue-router' import { useSessionStore } from '../store/sessionStore' import { useFeatureFlagStore } from '../store/featureFlagStore' +import { useTelemetryStore } from '../store/telemetryStore' import { authApi } from '../api/authApi' import type { FeatureFlags } from '../types/feature-flags' import type { LinkedAccount } from '../types/auth' @@ -14,6 +15,7 @@ const router = useRouter() const route = useRoute() const session = useSessionStore() const featureFlags = useFeatureFlagStore() +const telemetry = useTelemetryStore() const currentPassword = ref('') const newPassword = ref('') @@ -271,6 +273,52 @@ const flagLabels: Record = { + +
+

Telemetry & Privacy

+

+ Taskdeck can collect anonymous usage data to help improve the product. + No personal information, card content, board names, or user-generated text is ever collected. + Telemetry is off by default and requires your explicit opt-in. +

+

+ Your browser has Do Not Track or Global Privacy Control enabled. + Telemetry consent is not auto-restored across sessions. You may still opt in below. +

+
+ + +
+

+ Telemetry is enabled. Anonymous usage events will be sent periodically. +

+

+ Telemetry is disabled. No usage data is collected or sent. +

+
+ What data is collected? +
    +
  • Page navigation events (which pages are visited, not content)
  • +
  • Feature usage counts (captures, proposals, board loads)
  • +
  • Error codes (no error messages or stack traces)
  • +
  • Workspace mode and app version
  • +
  • Anonymous session ID (rotated on each app restart)
  • +
+

+ We never collect: card titles, board names, usernames, emails, passwords, + file paths, or any user-generated content. +

+
+
+

Feature Flags

@@ -322,6 +370,15 @@ const flagLabels: Record = { .td-flag-row { display: flex; justify-content: space-between; align-items: center; padding: var(--td-space-2) 0; } .td-flag-label { font-size: var(--td-font-sm); color: var(--td-text-primary); } .td-checkbox { width: 18px; height: 18px; cursor: pointer; } +.td-telemetry-status { font-size: var(--td-font-sm); margin-top: var(--td-space-3); padding: var(--td-space-2) var(--td-space-3); border-radius: var(--td-radius-md); } +.td-telemetry-status--on { background: var(--td-color-success-light); color: var(--td-color-success); } +.td-telemetry-status--off { background: var(--td-surface-tertiary); color: var(--td-text-secondary); } +.td-telemetry-status--dnt { background: var(--td-color-warning-light, #fef3cd); color: var(--td-color-warning, #856404); } +.td-telemetry-details { margin-top: var(--td-space-4); } +.td-telemetry-summary { cursor: pointer; font-size: var(--td-font-sm); color: var(--td-text-secondary); font-weight: 500; } +.td-telemetry-list { font-size: var(--td-font-sm); color: var(--td-text-secondary); padding-left: var(--td-space-6); margin-top: var(--td-space-2); list-style: disc; } +.td-telemetry-list li { margin-bottom: var(--td-space-1); } +.td-telemetry-note { font-size: var(--td-font-xs); color: var(--td-text-tertiary); margin-top: var(--td-space-2); font-style: italic; } /* GitHub button styling */ .td-btn--github {