diff --git a/PRIVACY.md b/PRIVACY.md index 64ef7cd..4ad7afb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -21,5 +21,10 @@ QueryWatch packages use the shared telemetry package, but not every package uses - The main `KeelMatrix.QueryWatch` library uses activation and heartbeat through its session lifecycle. - The `qwatch` CLI sends an activation event on normal execution, but does not send heartbeat events. +- `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` do not emit telemetry. +- `qwatch telemetry disable` writes a repo-local opt-out only when it can safely create or update a qwatch-managed config. +- `qwatch telemetry enable` only removes or neutralizes qwatch-managed repo-local opt-out state. +- QueryWatch-owned repo-local configs use `managedBy: "qwatch"` as the ownership marker. +- Higher-precedence process environment variables still override repo-local config. QueryWatch does not add product-specific telemetry fields on top of the shared telemetry package behavior documented above. If that changes in a way that affects privacy, this file will be updated. diff --git a/README.md b/README.md index cebf15b..f8d7ffa 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,16 @@ If a summary is top-N sampled, budgets are evaluated only over those captured ev ``` +Usage: + qwatch --input file.json [options] + qwatch telemetry [options] + +Commands: +telemetry status [--json] Show effective telemetry state and repo-local config status for the current repo. +telemetry disable Write a qwatch-managed repo-local telemetry opt-out. +telemetry enable Remove or neutralize qwatch-managed repo-local telemetry opt-out. + +Options: --input Input JSON summary file. (repeatable) --max-queries N Fail if total query count exceeds N. --max-average-ms N Fail if average duration exceeds N ms. @@ -220,7 +230,7 @@ If a summary is top-N sampled, budgets are evaluated only over those captured ev --baseline Baseline summary JSON to compare against. --baseline-allow-percent P Allow +P% regression vs baseline before failing. --write-baseline Write current aggregated summary to --baseline. ---budget "=" Per-pattern query count budget (repeatable). (repeatable) +--budget "=" Per-pattern query count budget. (repeatable) Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex. --require-full-events Fail if input summaries are top-N sampled. --help Show this help. @@ -232,6 +242,7 @@ Multi-file support: - repeat `--input` to aggregate summaries from multiple test projects - compare current results against a baseline summary - write GitHub Actions step summaries automatically when running in CI +- inspect or manage repo-local telemetry opt-out state with `qwatch telemetry status|disable|enable` ## Troubleshooting @@ -249,6 +260,8 @@ See: - [PRIVACY.md](PRIVACY.md) for the QueryWatch-specific summary - [KeelMatrix.Telemetry README](https://github.com/KeelMatrix/Telemetry#readme) for the maintained telemetry behavior and opt-out details +For the CLI, `qwatch telemetry disable` writes a repo-local opt-out file and `qwatch telemetry enable` removes or neutralizes only qwatch-managed repo-local opt-out state. QueryWatch-owned files use `managedBy: "qwatch"` as the ownership marker. Higher-precedence process environment variables still win, and existing non-qwatch-managed repo-local config is left untouched. + ## License MIT diff --git a/src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs b/src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs similarity index 87% rename from src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs rename to src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs index 157439c..b45ea05 100644 --- a/src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs +++ b/src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs @@ -3,7 +3,7 @@ using KeelMatrix.Telemetry; namespace KeelMatrix.QueryWatch { - internal static class QueryWatchTelemetry { + internal static class TelemetryHost { private static readonly Client Client = new("QueryWatch", typeof(QueryWatchSession)); internal static void TrackActivation() => Client.TrackActivation(); diff --git a/src/KeelMatrix.QueryWatch/QueryWatchSession.cs b/src/KeelMatrix.QueryWatch/QueryWatchSession.cs index d6a1a6b..2e5a7f0 100644 --- a/src/KeelMatrix.QueryWatch/QueryWatchSession.cs +++ b/src/KeelMatrix.QueryWatch/QueryWatchSession.cs @@ -31,7 +31,7 @@ public QueryWatchSession(QueryWatchOptions? options = null) { Options = options ?? new QueryWatchOptions(); StartedAt = DateTimeOffset.UtcNow; - QueryWatchTelemetry.TrackActivation(); + TelemetryHost.TrackActivation(); } /// Options for this session. @@ -131,7 +131,7 @@ private QueryWatchReport StopInternal() { if (Interlocked.CompareExchange(ref _stopped, 1, 0) == 0) { StoppedAt = now; - QueryWatchTelemetry.TrackHeartbeat(); + TelemetryHost.TrackHeartbeat(); } List snapshot; diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs index 47b0fe7..66c56a4 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineToleranceTests.cs @@ -11,9 +11,12 @@ public void Within_Tolerance_ExitCode_Ok() { string baseline = Path.Combine(AppContext.BaseDirectory, "Fixtures", "baseline.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", current, - "--baseline", baseline, - "--baseline-allow-percent", "10" + "--input", + current, + "--baseline", + baseline, + "--baseline-allow-percent", + "10" ]); _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); @@ -25,9 +28,12 @@ public void Beyond_Tolerance_ExitCode_BaselineRegression() { string baseline = Path.Combine(AppContext.BaseDirectory, "Fixtures", "baseline.json"); (int code, string _, string? stderr) = CliRunner.Run([ - "--input", current, - "--baseline", baseline, - "--baseline-allow-percent", "10" + "--input", + current, + "--baseline", + baseline, + "--baseline-allow-percent", + "10" ]); _ = code.Should().Be(5); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs index 8c28f3a..ac1334b 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/BaselineWriteTests.cs @@ -15,8 +15,10 @@ public void WriteBaseline_Creates_File_And_Prints_Message() { try { // Act (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--baseline", baselinePath, + "--input", + f, + "--baseline", + baselinePath, "--write-baseline" ]); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs index 8ce0051..95e4454 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/CliRunner.cs @@ -9,7 +9,10 @@ internal static class CliRunner { private static string? _publishedDir; private static string? _publishedDll; - public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (string Key, string Value)[]? env = null) { + public static (int ExitCode, string StdOut, string StdErr) Run( + string[] args, + (string Key, string? Value)[]? env = null, + string? workingDirectory = null) { string repoRoot = FindRepoRoot(); EnsurePublished(repoRoot, out string? dllPath, out string? workDir); @@ -18,7 +21,7 @@ public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (s RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, - WorkingDirectory = workDir + WorkingDirectory = workingDirectory ?? workDir }; // Run the published app: dotnet -- @@ -26,8 +29,12 @@ public static (int ExitCode, string StdOut, string StdErr) Run(string[] args, (s foreach (string a in args) psi.ArgumentList.Add(a); if (env is not null) { - foreach (var (k, v) in env) - psi.Environment[k] = v; + foreach (var (k, v) in env) { + if (v is null) + _ = psi.Environment.Remove(k); + else + psi.Environment[k] = v; + } } using var proc = Process.Start(psi)!; @@ -126,5 +133,7 @@ private static string FindRepoRoot() { } return AppContext.BaseDirectory; } + + internal static string GetRepoRoot() => FindRepoRoot(); } } diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs index d8f6e28..76a1640 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/EndToEndLibraryCliTests.cs @@ -35,14 +35,18 @@ public void LibraryJson_MaxQueries_Pass_Then_Fail() { var json = CreateLibraryJson(totalEvents: 2, sampleTop: 10, out _); (int okCode, string? okOut, string? okErr) = CliRunner.Run([ - "--input", json, - "--max-queries", "3" + "--input", + json, + "--max-queries", + "3" ]); _ = okCode.Should().Be(0, okOut + Environment.NewLine + okErr); (int failCode, string _, string? failErr) = CliRunner.Run([ - "--input", json, - "--max-queries", "1" + "--input", + json, + "--max-queries", + "1" ]); _ = failCode.Should().Be(4); _ = failErr.Should().Contain("Max queries exceeded"); @@ -54,15 +58,19 @@ public void LibraryJson_PatternBudget_Pass_Then_Fail() { // Allow exactly the predictable match (int okCode, string? okOut, string? okErr) = CliRunner.Run([ - "--input", json, - "--budget", "SELECT * FROM Users*=1" + "--input", + json, + "--budget", + "SELECT * FROM Users*=1" ]); _ = okCode.Should().Be(0, okOut + Environment.NewLine + okErr); // Now disallow it (int badCode, string _, string? badErr) = CliRunner.Run([ - "--input", json, - "--budget", "SELECT * FROM Users*=0" + "--input", + json, + "--budget", + "SELECT * FROM Users*=0" ]); _ = badCode.Should().Be(4); _ = badErr.Should().Contain("Budget violations"); @@ -74,8 +82,10 @@ public void Baseline_Write_Then_Compare_With_Tolerance() { var baselinePath = Path.Combine(Path.GetTempPath(), "qwatch-baseline-" + Guid.NewGuid().ToString("N"), "baseline.json"); (int writeCode, string? writeOut, string? writeErr) = CliRunner.Run([ - "--input", current1, - "--baseline", baselinePath, + "--input", + current1, + "--baseline", + baselinePath, "--write-baseline" ]); _ = writeCode.Should().Be(0, writeOut + Environment.NewLine + writeErr); @@ -85,17 +95,23 @@ public void Baseline_Write_Then_Compare_With_Tolerance() { // Generous tolerance -> pass (int passCode, string? passOut, string? passErr) = CliRunner.Run([ - "--input", current2, - "--baseline", baselinePath, - "--baseline-allow-percent", "80" + "--input", + current2, + "--baseline", + baselinePath, + "--baseline-allow-percent", + "80" ]); _ = passCode.Should().Be(0, passOut + Environment.NewLine + passErr); // Tight tolerance -> fail with baseline regression code (int failCode, string _, string? failErr) = CliRunner.Run([ - "--input", current2, - "--baseline", baselinePath, - "--baseline-allow-percent", "10" + "--input", + current2, + "--baseline", + baselinePath, + "--baseline-allow-percent", + "10" ]); _ = failCode.Should().Be(5); _ = failErr.Should().Contain("Baseline regressions"); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs index d5c311a..f98a2b0 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/InvalidInputAndBudgetTests.cs @@ -45,7 +45,8 @@ public void Missing_Budget_Value_Shows_Parse_Error() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, + "--input", + f, "--budget" // missing value ]); @@ -58,8 +59,10 @@ public void Invalid_Budget_Spec_Returns_InvalidArguments() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "not-a-valid-spec" // lacks = and max + "--input", + f, + "--budget", + "not-a-valid-spec" // lacks = and max ]); _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); // ExitCodes.InvalidArguments diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs index 4b8bccd..5e2aa81 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/MultiFileAggregationTests.cs @@ -11,18 +11,24 @@ public void Aggregates_Across_Multiple_Files_And_Respects_MaxQueries() { string f2 = Path.Combine(AppContext.BaseDirectory, "Fixtures", "agg_b.json"); (int exitOk, string? stdoutOk, string? stderrOk) = CliRunner.Run([ - "--input", f1, - "--input", f2, - "--max-queries", "5" + "--input", + f1, + "--input", + f2, + "--max-queries", + "5" ]); _ = exitOk.Should().Be(0, stdoutOk + Environment.NewLine + stderrOk); _ = stdoutOk.Should().Contain("files 2"); _ = stdoutOk.Should().Contain("Queries: 5"); (int exitFail, string _, string? stderrFail) = CliRunner.Run([ - "--input", f1, - "--input", f2, - "--max-queries", "4" + "--input", + f1, + "--input", + f2, + "--max-queries", + "4" ]); _ = exitFail.Should().Be(4); _ = stderrFail.Should().Contain("Budget violations:"); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs index 338aeeb..d86b7d2 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetTests.cs @@ -10,8 +10,10 @@ public void Exceeds_Pattern_Budget_Returns_BudgetExceeded() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run( [ - "--input", f, - "--budget", "SELECT * FROM Users*=1" + "--input", + f, + "--budget", + "SELECT * FROM Users*=1" ]); // For pattern budgets the CLI returns 4 when the count exceeds the budget. @@ -26,8 +28,10 @@ public void Meets_Pattern_Budget_Returns_Ok() { string f = Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run( [ - "--input", f, - "--budget", "SELECT * FROM Users*=2" + "--input", + f, + "--budget", + "SELECT * FROM Users*=2" ]); _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs index a4acfde..805ce75 100644 --- a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/PatternBudgetWildcardsAndRegexTests.cs @@ -11,15 +11,19 @@ public void Wildcard_QuestionMark_Matches_Single_Character_And_Ignores_Case() { // Lowercase pattern, '?' for one char in "Users", '*' for the rest of the line (int ok, string? stdoutOk, string? stderrOk) = CliRunner.Run([ - "--input", f, - "--budget", "select * from user?* = 2".Replace(" ", "") // avoid quoting/space headaches + "--input", + f, + "--budget", + "select * from user?* = 2".Replace(" ", "") // avoid quoting/space headaches ]); _ = ok.Should().Be(0, stdoutOk + Environment.NewLine + stderrOk); // Overly strict budget should fail (2 matches > 1 allowed) (int fail, string? stdoutFail, string? stderrFail) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT*FROM*User?*=1" + "--input", + f, + "--budget", + "SELECT*FROM*User?*=1" ]); _ = fail.Should().Be(4, stdoutFail + Environment.NewLine + stderrFail); } @@ -30,15 +34,19 @@ public void Regex_Budget_Is_Case_Insensitive_And_Anchored_From_Start() { // Should match both SELECTs in pattern.json (int ok, string? so1, string? se1) = CliRunner.Run([ - "--input", f, - "--budget", @"regex:^select\s+\*\s+from\s+users\b.*=2" + "--input", + f, + "--budget", + @"regex:^select\s+\*\s+from\s+users\b.*=2" ]); _ = ok.Should().Be(0, so1 + Environment.NewLine + se1); // Only allow 1 -> should fail because there are 2 matches (int bad, string? so2, string? se2) = CliRunner.Run([ - "--input", f, - "--budget", @"regex:^SELECT\s+\*\s+FROM\s+Users\b.*=1" + "--input", + f, + "--budget", + @"regex:^SELECT\s+\*\s+FROM\s+Users\b.*=1" ]); _ = bad.Should().Be(4, so2 + Environment.NewLine + se2); } @@ -58,8 +66,10 @@ public void Zero_Allowed_Count_Fails_When_There_Are_Matches() { string f = System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT * FROM Users*=0" + "--input", + f, + "--budget", + "SELECT * FROM Users*=0" ]); _ = code.Should().Be(4, stdout + Environment.NewLine + stderr); // BudgetExceeded } @@ -69,9 +79,12 @@ public void Multiple_Budgets_One_Over_Still_Triggers_Failure() { string f = System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "pattern.json"); (int code, string? stdout, string? stderr) = CliRunner.Run([ - "--input", f, - "--budget", "SELECT * FROM Users*=1", // over (2 > 1) - "--budget", "INSERT INTO Users*=2" // under (1 <= 2) + "--input", + f, + "--budget", + "SELECT * FROM Users*=1", // over (2 > 1) + "--budget", + "INSERT INTO Users*=2" // under (1 <= 2) ]); _ = code.Should().Be(4, stdout + Environment.NewLine + stderr); } diff --git a/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs new file mode 100644 index 0000000..6d83106 --- /dev/null +++ b/tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs @@ -0,0 +1,555 @@ +// Copyright (c) KeelMatrix + +using System.Text.Json; +using FluentAssertions; +using KeelMatrix.QueryWatch.Cli; +using KeelMatrix.QueryWatch.Cli.Telemetry; +using Xunit; + +namespace KeelMatrix.QueryWatch.Cli.IntegrationTests { + [CollectionDefinition(CollectionName)] + public sealed class TelemetryCommandCollection : ICollectionFixture { + public const string CollectionName = "Telemetry CLI"; + } + + public sealed class TelemetryTestCleanupFixture : IDisposable { + public void Dispose() { + CleanupTempDirectories("qwatch-telemetry-*"); + CleanupTempDirectories("qwatch-no-repo-*"); + } + + private static void CleanupTempDirectories(string searchPattern) { + try { + foreach (DirectoryInfo directory in new DirectoryInfo(Path.GetTempPath()).EnumerateDirectories(searchPattern)) { + try { + directory.Delete(recursive: true); + } + catch { + // Best-effort cleanup after the collection finishes. + } + } + } + catch { + // Ignore temp-folder enumeration failures in test cleanup. + } + } + } + + [Collection(TelemetryCommandCollection.CollectionName)] + public sealed class TelemetryCommandTests { + [Fact] + public void Telemetry_Status_Shows_Enabled_When_No_Overrides_Exist() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: enabled"); + _ = stdout.Should().Contain("Source: none"); + _ = NormalizeForAssertion(stdout).Should().Contain($"Repo: {NormalizeForAssertion(repo.Root)}"); + _ = stdout.Should().Contain("Repo-local config: missing"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_Process_Environment_Disables() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: [.. RepoScope.TelemetryEnvironment, ("KEELMATRIX_NO_TELEMETRY", "1")], + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: process environment"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + _ = stdout.Should().Contain("Scope: process-level"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_Repository_Config_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: keelmatrix.telemetry.json"); + _ = NormalizeForAssertion(stdout).Should().Contain(NormalizeForAssertion(Path.Combine(repo.Root, "keelmatrix.telemetry.json"))); + _ = stdout.Should().Contain("Scope: repo-local"); + _ = stdout.Should().Contain("Repo-local config: not qwatch-managed"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_DotEnvLocal_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env.local"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: .env.local"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + } + + [Fact] + public void Telemetry_Status_Shows_Disabled_When_DotEnv_Disables() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: disabled"); + _ = stdout.Should().Contain("Source: .env"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + } + + [Fact] + public void Telemetry_Status_Shows_Process_Override_When_Process_Environment_Masks_Repo_Config() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{" + Environment.NewLine + " \"disabled\": true" + Environment.NewLine + "}" + Environment.NewLine, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: [.. RepoScope.TelemetryEnvironment, ("KEELMATRIX_NO_TELEMETRY", "0")], + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Telemetry: enabled"); + _ = stdout.Should().Contain("Source: process environment"); + _ = stdout.Should().Contain("Variable: KEELMATRIX_NO_TELEMETRY"); + _ = stdout.Should().Contain("Repo-local config: not qwatch-managed"); + } + + [Fact] + public void Telemetry_Status_Shows_QueryWatchManaged_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"managedBy\": \"qwatch\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = stdout.Should().Contain("Repo-local config: qwatch-managed"); + } + + [Fact] + public void Telemetry_Status_Json_Uses_Status_Model() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("KEELMATRIX_NO_TELEMETRY=1" + Environment.NewLine, ".env.local"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + JsonElement effectiveStatus = doc.RootElement.GetProperty("effectiveStatus"); + _ = effectiveStatus.GetProperty("isEnabled").GetBoolean().Should().BeFalse(); + _ = effectiveStatus.GetProperty("winningSourceKind").GetString().Should().Be("dotEnvLocal"); + _ = NormalizeForAssertion(effectiveStatus.GetProperty("winningPath").GetString()).Should().Be(NormalizeForAssertion(Path.Combine(repo.Root, ".env.local"))); + _ = effectiveStatus.GetProperty("winningVariableName").GetString().Should().Be("KEELMATRIX_NO_TELEMETRY"); + _ = effectiveStatus.GetProperty("scope").GetString().Should().Be("repoLocal"); + _ = NormalizeForAssertion(effectiveStatus.GetProperty("repoRoot").GetString()).Should().Be(NormalizeForAssertion(repo.Root)); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("missing"); + _ = NormalizeForAssertion(doc.RootElement.GetProperty("repoLocalConfigPath").GetString()).Should().Be(NormalizeForAssertion(Path.Combine(repo.Root, "keelmatrix.telemetry.json"))); + } + + [Fact] + public void Telemetry_Status_Json_Reports_Invalid_RepoLocal_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("invalidJson"); + } + + [Fact] + public void Telemetry_Status_Json_Reports_Oversized_RepoLocal_Config_State() { + using RepoScope repo = RepoScope.Create(); + repo.WriteFile(new string('x', RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes + 1), "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status", "--json"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + + using JsonDocument doc = JsonDocument.Parse(stdout); + _ = doc.RootElement.GetProperty("repoLocalConfigState").GetString().Should().Be("tooLarge"); + } + + [Fact] + public void Telemetry_Disable_Creates_Qwatch_Managed_Repository_Config_When_None_Exists() { + using RepoScope repo = RepoScope.Create(); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + string configPath = Path.Combine(repo.Root, "keelmatrix.telemetry.json"); + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); + _ = doc.RootElement.GetProperty("disabled").GetBoolean().Should().BeTrue(); + _ = doc.RootElement.GetProperty("managedBy").GetString().Should().Be("qwatch"); + } + + [Fact] + public void Telemetry_Disable_Refuses_To_Overwrite_Non_Qwatch_Managed_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": false," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + string originalConfig = File.ReadAllText(configPath); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("Refusing to overwrite existing repo-local telemetry config because it is not qwatch-managed"); + _ = File.ReadAllText(configPath).Should().Be(originalConfig); + } + + [Fact] + public void Telemetry_Disable_Refuses_Invalid_Json_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("contains invalid JSON"); + _ = File.ReadAllText(configPath).Should().Be("{ not-json"); + } + + [Fact] + public void Telemetry_Disable_Refuses_Oversized_Config() { + using RepoScope repo = RepoScope.Create(); + string oversized = new string('x', RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes + 1); + string configPath = repo.WriteFile(oversized, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "disable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("exceeds the"); + _ = File.ReadAllText(configPath).Should().Be(oversized); + } + + [Fact] + public void Telemetry_Enable_Removes_Qwatch_Managed_Config_File() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"managedBy\": \"qwatch\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeFalse(); + _ = stdout.Should().Contain("Repo-local qwatch-managed telemetry opt-out removed"); + _ = stdout.Should().Contain("Telemetry: enabled"); + } + + [Fact] + public void Telemetry_Enable_Rewrites_Config_To_Enabled_When_Preserving_Other_Content() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile( + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"managedBy\": \"qwatch\"," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine, + "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(0, stdout + Environment.NewLine + stderr); + _ = File.Exists(configPath).Should().BeTrue(); + + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(configPath)); + _ = doc.RootElement.GetProperty("channel").GetString().Should().Be("dev"); + _ = doc.RootElement.GetProperty("managedBy").GetString().Should().Be("qwatch"); + _ = doc.RootElement.TryGetProperty("disabled", out _).Should().BeFalse(); + _ = stdout.Should().Contain("Repo-local qwatch-managed telemetry config updated"); + } + + [Fact] + public void Telemetry_Enable_Does_Not_Modify_Non_Qwatch_Managed_Config() { + using RepoScope repo = RepoScope.Create(); + string originalConfig = + "{" + Environment.NewLine + + " \"disabled\": true," + Environment.NewLine + + " \"channel\": \"dev\"" + Environment.NewLine + + "}" + Environment.NewLine; + string configPath = repo.WriteFile(originalConfig, "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = File.ReadAllText(configPath).Should().Be(originalConfig); + _ = stderr.Should().Contain("not qwatch-managed and was left unchanged"); + } + + [Fact] + public async Task Telemetry_Commands_Do_Not_Call_TrackActivation() { + using RepoScope repo = RepoScope.Create(); + using EnvironmentVariableSnapshot envSnapshot = new("KEELMATRIX_NO_TELEMETRY", "DOTNET_CLI_TELEMETRY_OPTOUT", "DO_NOT_TRACK"); + using CurrentDirectoryScope __ = new(repo.WorkingDirectory); + using ConsoleCaptureScope console = new(); + RecordingCliTelemetry telemetry = new(); + ITelemetryHost previousTelemetry = TelemetryHost.Current; + TelemetryHost.Current = telemetry; + + try { + Environment.SetEnvironmentVariable("KEELMATRIX_NO_TELEMETRY", null); + Environment.SetEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT", null); + Environment.SetEnvironmentVariable("DO_NOT_TRACK", null); + + int telemetryCode = await Program.RunAsync(["telemetry", "status"]); + _ = telemetryCode.Should().Be(0, console.StdOut + Environment.NewLine + console.StdErr); + _ = telemetry.ActivationCalls.Should().Be(0); + + console.Clear(); + + string inputPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "current_ok.json"); + int normalCode = await Program.RunAsync(["--input", inputPath]); + _ = normalCode.Should().Be(0, console.StdOut + Environment.NewLine + console.StdErr); + _ = telemetry.ActivationCalls.Should().Be(1); + } + finally { + TelemetryHost.Current = previousTelemetry; + } + } + + [Fact] + public void Telemetry_Enable_Refuses_Invalid_Json_Config() { + using RepoScope repo = RepoScope.Create(); + string configPath = repo.WriteFile("{ not-json", "keelmatrix.telemetry.json"); + + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "enable"], + env: RepoScope.TelemetryEnvironment, + workingDirectory: repo.WorkingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stdout.Should().BeEmpty(); + _ = stderr.Should().Contain("contains invalid JSON"); + _ = File.ReadAllText(configPath).Should().Be("{ not-json"); + } + + [Fact] + public void Telemetry_Help_And_Flag_Docs_Include_Command_Group() { + (int helpCode, string helpOut, string helpErr) = CliRunner.Run(["--help"]); + _ = helpCode.Should().Be(0, helpOut + Environment.NewLine + helpErr); + _ = helpOut.Should().Contain("qwatch telemetry [options]"); + _ = helpOut.Should().Contain("telemetry status [--json]"); + + (int flagsCode, string flagsOut, string flagsErr) = CliRunner.Run(["--print-flags-md"]); + _ = flagsCode.Should().Be(0, flagsOut + Environment.NewLine + flagsErr); + _ = flagsOut.Should().Contain("qwatch telemetry [options]"); + _ = flagsOut.Should().Contain("telemetry enable"); + } + + [Fact] + public void Telemetry_Status_Fails_When_No_Repository_Root_Is_Found() { + string workingDirectory = Path.Combine(Path.GetTempPath(), "qwatch-no-repo-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + + try { + (int code, string stdout, string stderr) = CliRunner.Run( + ["telemetry", "status"], + env: RepoScope.EmptyTelemetryEnvironment, + workingDirectory: workingDirectory); + + _ = code.Should().Be(1, stdout + Environment.NewLine + stderr); + _ = stderr.Should().Contain("No repository root could be resolved"); + } + finally { + try { Directory.Delete(workingDirectory, recursive: true); } catch { /* best-effort */ } + } + } + + private sealed class RepoScope : IDisposable { + internal static readonly (string Key, string? Value)[] EmptyTelemetryEnvironment = [ + ("KEELMATRIX_NO_TELEMETRY", null), + ("DOTNET_CLI_TELEMETRY_OPTOUT", null), + ("DO_NOT_TRACK", null) + ]; + + private RepoScope(string root) { + Root = root; + WorkingDirectory = Path.Combine(root, "src", "tests"); + Directory.CreateDirectory(Path.Combine(root, ".git")); + Directory.CreateDirectory(WorkingDirectory); + } + + internal string Root { get; } + internal string WorkingDirectory { get; } + internal static (string Key, string? Value)[] TelemetryEnvironment => EmptyTelemetryEnvironment; + + internal static RepoScope Create() { + string root = Path.Combine(Path.GetTempPath(), "qwatch-telemetry-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return new RepoScope(root); + } + + internal string WriteFile(string content, params string[] segments) { + string path = Path.Combine([Root, .. segments]); + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(path, content); + return path; + } + + public void Dispose() { + try { + Directory.Delete(Root, recursive: true); + } + catch { + // Best-effort cleanup for temp test repos. + } + } + } + + private sealed class ConsoleCaptureScope : IDisposable { + private readonly TextWriter originalOut; + private readonly TextWriter originalErr; + private readonly StringWriter stdoutWriter = new(); + private readonly StringWriter stderrWriter = new(); + + public ConsoleCaptureScope() { + originalOut = Console.Out; + originalErr = Console.Error; + Console.SetOut(stdoutWriter); + Console.SetError(stderrWriter); + } + + public string StdOut => stdoutWriter.ToString(); + public string StdErr => stderrWriter.ToString(); + + public void Clear() { + stdoutWriter.GetStringBuilder().Clear(); + stderrWriter.GetStringBuilder().Clear(); + } + + public void Dispose() { + Console.SetOut(originalOut); + Console.SetError(originalErr); + stdoutWriter.Dispose(); + stderrWriter.Dispose(); + } + } + + private sealed class CurrentDirectoryScope : IDisposable { + private readonly string originalCurrentDirectory; + + public CurrentDirectoryScope(string currentDirectory) { + originalCurrentDirectory = Environment.CurrentDirectory; + Environment.CurrentDirectory = currentDirectory; + } + + public void Dispose() { + Environment.CurrentDirectory = originalCurrentDirectory; + } + } + + private static string NormalizeForAssertion(string? value) { + value.Should().NotBeNull(); + + if (!OperatingSystem.IsMacOS()) + return value!; + + return value!.Replace("/private/var/", "/var/", StringComparison.Ordinal); + } + + private sealed class EnvironmentVariableSnapshot : IDisposable { + private readonly (string Name, string? Value)[] snapshot; + + public EnvironmentVariableSnapshot(params string[] names) { + snapshot = new (string, string?)[names.Length]; + for (int i = 0; i < names.Length; i++) { + string name = names[i]; + snapshot[i] = (name, Environment.GetEnvironmentVariable(name)); + } + } + + public void Dispose() { + foreach (var (name, value) in snapshot) + Environment.SetEnvironmentVariable(name, value); + } + } + + private sealed class RecordingCliTelemetry : ITelemetryHost { + public int ActivationCalls { get; private set; } + + public void TrackActivation() { + ActivationCalls++; + } + } + + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs new file mode 100644 index 0000000..9aa5c85 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/CliCommandParser.cs @@ -0,0 +1,42 @@ +// Copyright (c) KeelMatrix + +namespace KeelMatrix.QueryWatch.Cli.Options { + internal static class CliCommandParser { + public static RootParseResult Parse(string[] args) { + if (args.Length > 0 && string.Equals(args[0], "telemetry", StringComparison.OrdinalIgnoreCase)) { + return RootParseResult.FromTelemetry(TelemetryCommandLineOptions.Parse(args[1..])); + } + + return RootParseResult.FromAnalysis(CommandLineOptions.Parse(args)); + } + } + + internal readonly record struct RootParseResult( + bool Success, + CommandLineOptions? Options, + TelemetryCommandLineOptions? TelemetryOptions, + string? ErrorMessage, + bool ShowHelp, + string HelpText) { + + public static RootParseResult FromAnalysis(ParseResult parsed) { + return new RootParseResult( + parsed.Success, + parsed.Success ? parsed.Options : null, + null, + parsed.ErrorMessage, + parsed.ShowHelp, + CommandLineOptions.HelpText); + } + + public static RootParseResult FromTelemetry(TelemetryParseResult parsed) { + return new RootParseResult( + parsed.Success, + null, + parsed.Success ? parsed.Options : null, + parsed.ErrorMessage, + parsed.ShowHelp, + TelemetryCommandLineOptions.HelpText); + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs index e617d4a..0842a60 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs @@ -4,6 +4,7 @@ namespace KeelMatrix.QueryWatch.Cli.Options { internal sealed record CliOption(string Flag, string? ValueSyntax, string Description, string? Notes = null, bool Repeatable = false); + internal sealed record CliCommand(string Command, string Description); internal static class CliSpec { public static readonly CliOption[] Options = [ @@ -14,33 +15,38 @@ internal static class CliSpec { new CliOption("--baseline", "", "Baseline summary JSON to compare against."), new CliOption("--baseline-allow-percent", "P", "Allow +P% regression vs baseline before failing."), new CliOption("--write-baseline", null, "Write current aggregated summary to --baseline."), - new CliOption("--budget", "\"=\"", "Per-pattern query count budget (repeatable).", + new CliOption("--budget", "\"=\"", "Per-pattern query count budget.", Notes: "Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex.", Repeatable: true), new CliOption("--require-full-events", null, "Fail if input summaries are top-N sampled."), new CliOption("--help", null, "Show this help.") ]; + public static readonly CliCommand[] TelemetryCommands = [ + new CliCommand("telemetry status [--json]", "Show effective telemetry state and repo-local config status for the current repo."), + new CliCommand("telemetry disable", "Write a qwatch-managed repo-local telemetry opt-out."), + new CliCommand("telemetry enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out.") + ]; + public static string BuildHelpText() { // column widths const int leftWidth = 30; StringBuilder sb = new(); _ = sb.AppendLine("QueryWatch CLI"); _ = sb.AppendLine(); - _ = sb.AppendLine("Usage: qwatch --input file.json [options]"); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch --input file.json [options]"); + _ = sb.AppendLine(" qwatch telemetry [options]"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + foreach (CliCommand command in TelemetryCommands) { + _ = AppendAlignedLine(sb, leftWidth, command.Command, command.Description); + } _ = sb.AppendLine(); _ = sb.AppendLine("Options:"); foreach (CliOption o in Options) { var left = o.Flag + (o.ValueSyntax is not null ? " " + o.ValueSyntax : string.Empty); var repeat = o.Repeatable ? " (repeatable)" : string.Empty; - _ = sb.Append(" "); - if (left.Length >= leftWidth) { - _ = sb.AppendLine(left); - _ = sb.Append(' ', leftWidth); - } - else { - _ = sb.Append(left.PadRight(leftWidth)); - } - _ = sb.AppendLine(o.Description + repeat); + _ = AppendAlignedLine(sb, leftWidth, left, o.Description + repeat); if (!string.IsNullOrWhiteSpace(o.Notes)) { _ = sb.Append(' ', 2 + leftWidth); _ = sb.AppendLine(o.Notes); @@ -49,9 +55,41 @@ public static string BuildHelpText() { return sb.ToString(); } + public static string BuildTelemetryHelpText() { + const int leftWidth = 28; + StringBuilder sb = new(); + _ = sb.AppendLine("QueryWatch CLI telemetry"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch telemetry status [--json]"); + _ = sb.AppendLine(" qwatch telemetry disable"); + _ = sb.AppendLine(" qwatch telemetry enable"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + _ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state and repo-local config status for the current repo."); + _ = AppendAlignedLine(sb, leftWidth, "disable", "Write a qwatch-managed repo-local telemetry opt-out."); + _ = AppendAlignedLine(sb, leftWidth, "enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out."); + _ = sb.AppendLine(); + _ = sb.AppendLine("Options:"); + _ = AppendAlignedLine(sb, leftWidth, "--json", "Output telemetry status as JSON."); + _ = AppendAlignedLine(sb, leftWidth, "--help", "Show this help."); + return sb.ToString(); + } + public static string BuildReadmeFlagsMarkdown() { StringBuilder sb = new(); _ = sb.AppendLine("```"); + _ = sb.AppendLine("Usage:"); + _ = sb.AppendLine(" qwatch --input file.json [options]"); + _ = sb.AppendLine(" qwatch telemetry [options]"); + _ = sb.AppendLine(); + _ = sb.AppendLine("Commands:"); + foreach (CliCommand command in TelemetryCommands) { + var left = command.Command.PadRight(28); + _ = sb.AppendLine($"{left} {command.Description}"); + } + _ = sb.AppendLine(); + _ = sb.AppendLine("Options:"); foreach (CliOption o in Options) { var left = (o.Flag + (o.ValueSyntax is not null ? " " + o.ValueSyntax : string.Empty)).PadRight(28); var repeat = o.Repeatable ? " (repeatable)" : string.Empty; @@ -63,5 +101,19 @@ public static string BuildReadmeFlagsMarkdown() { _ = sb.AppendLine("```"); return sb.ToString(); } + + private static StringBuilder AppendAlignedLine(StringBuilder sb, int leftWidth, string left, string right) { + _ = sb.Append(" "); + if (left.Length >= leftWidth) { + _ = sb.AppendLine(left); + _ = sb.Append(' ', leftWidth); + } + else { + _ = sb.Append(left.PadRight(leftWidth)); + } + + _ = sb.AppendLine(right); + return sb; + } } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs b/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs new file mode 100644 index 0000000..daab124 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Options/TelemetryCommandLineOptions.cs @@ -0,0 +1,73 @@ +// Copyright (c) KeelMatrix + +namespace KeelMatrix.QueryWatch.Cli.Options { + internal enum TelemetryCommandKind { + Status, + Disable, + Enable + } + + internal sealed class TelemetryCommandLineOptions { + public TelemetryCommandKind Command { get; init; } + public bool Json { get; init; } + public bool ShowHelp { get; init; } + + public static TelemetryParseResult Parse(string[] args) { + if (HasHelp(args)) + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { ShowHelp = true }); + + if (args.Length == 0) + return TelemetryParseResult.Error("Missing telemetry command. Use 'qwatch telemetry --help' to see available commands."); + + if (string.Equals(args[0], "status", StringComparison.OrdinalIgnoreCase)) + return ParseStatus(args[1..]); + + if (string.Equals(args[0], "disable", StringComparison.OrdinalIgnoreCase)) + return ParseNoOptionCommand(TelemetryCommandKind.Disable, "disable", args[1..]); + + if (string.Equals(args[0], "enable", StringComparison.OrdinalIgnoreCase)) + return ParseNoOptionCommand(TelemetryCommandKind.Enable, "enable", args[1..]); + + return TelemetryParseResult.Error($"Unknown telemetry command: {args[0]}"); + } + + public static string HelpText => CliSpec.BuildTelemetryHelpText(); + + private static TelemetryParseResult ParseStatus(string[] args) { + bool json = false; + + foreach (string arg in args) { + if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase)) { + json = true; + continue; + } + + return TelemetryParseResult.Error($"Unknown telemetry status argument: {arg}"); + } + + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { + Command = TelemetryCommandKind.Status, + Json = json + }); + } + + private static TelemetryParseResult ParseNoOptionCommand(TelemetryCommandKind kind, string verb, string[] args) { + if (args.Length > 0) + return TelemetryParseResult.Error($"Unknown telemetry {verb} argument: {args[0]}"); + + return TelemetryParseResult.Successful(new TelemetryCommandLineOptions { Command = kind }); + } + + private static bool HasHelp(string[] args) { + return args.Any(static arg => + string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) + || string.Equals(arg, "/?", StringComparison.OrdinalIgnoreCase)); + } + } + + internal readonly record struct TelemetryParseResult(bool Success, TelemetryCommandLineOptions Options, string? ErrorMessage, bool ShowHelp) { + public static TelemetryParseResult Successful(TelemetryCommandLineOptions opts) => new(true, opts, null, opts.ShowHelp); + public static TelemetryParseResult Error(string error) => new(false, new TelemetryCommandLineOptions(), error, true); + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Program.cs b/tools/KeelMatrix.QueryWatch.Cli/Program.cs index debc4cf..65edba8 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/Program.cs +++ b/tools/KeelMatrix.QueryWatch.Cli/Program.cs @@ -2,10 +2,15 @@ using KeelMatrix.QueryWatch.Cli.Core; using KeelMatrix.QueryWatch.Cli.Options; +using KeelMatrix.QueryWatch.Cli.Telemetry; namespace KeelMatrix.QueryWatch.Cli { internal static class Program { - private static async Task Main(string[] args) { + private static Task Main(string[] args) { + return RunAsync(args); + } + + internal static async Task RunAsync(string[] args) { // 0) No arguments → show help if (args.Length == 0) { Console.WriteLine(CommandLineOptions.HelpText); @@ -18,13 +23,13 @@ private static async Task Main(string[] args) { return ExitCodes.Ok; } - ParseResult parsed = CommandLineOptions.Parse(args); + RootParseResult parsed = CliCommandParser.Parse(args); // 2) If the caller asked for help, always print it and return: // - 0 when parse succeeded (pure help) // - 1 when parse failed (help + error) if (parsed.ShowHelp) { - Console.WriteLine(CommandLineOptions.HelpText); + Console.WriteLine(parsed.HelpText); if (!parsed.Success && !string.IsNullOrEmpty(parsed.ErrorMessage)) await Console.Error.WriteLineAsync(parsed.ErrorMessage); return parsed.Success ? ExitCodes.Ok : ExitCodes.InvalidArguments; @@ -37,9 +42,12 @@ private static async Task Main(string[] args) { return ExitCodes.InvalidArguments; } + if (parsed.TelemetryOptions is not null) + return await TelemetryCommandHandler.ExecuteAsync(parsed.TelemetryOptions).ConfigureAwait(false); + // 4) Normal execution - QueryWatchCliTelemetry.TrackActivation(); - return await Runner.ExecuteAsync(parsed.Options); + TelemetryHost.TrackActivation(); + return await Runner.ExecuteAsync(parsed.Options!).ConfigureAwait(false); } } } diff --git a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs b/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs deleted file mode 100644 index f5929e1..0000000 --- a/tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) KeelMatrix - -using KeelMatrix.Telemetry; - -namespace KeelMatrix.QueryWatch.Cli { - internal static class QueryWatchCliTelemetry { - private static readonly Client Client = new("qwatchCLI", typeof(Program)); - - internal static void TrackActivation() => Client.TrackActivation(); - } -} diff --git a/tools/KeelMatrix.QueryWatch.Cli/README.md b/tools/KeelMatrix.QueryWatch.Cli/README.md index 6199ef2..b6bc797 100644 --- a/tools/KeelMatrix.QueryWatch.Cli/README.md +++ b/tools/KeelMatrix.QueryWatch.Cli/README.md @@ -31,6 +31,24 @@ Show help: qwatch --help ``` +Inspect effective telemetry state and repo-local config status for the current repo: + +```bash +qwatch telemetry status +``` + +Write a qwatch-managed repo-local telemetry opt-out for the current repo: + +```bash +qwatch telemetry disable +``` + +Remove a qwatch-managed repo-local telemetry opt-out for the current repo: + +```bash +qwatch telemetry enable +``` + Fail if total queries exceed 50: ```bash @@ -137,6 +155,8 @@ If you only need the file format in another tool, see the contracts package: `qwatch` sends a minimal anonymous telemetry activation event on normal CLI execution. +Telemetry management commands do not emit telemetry. Use `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` to inspect or manage the repo-local opt-out file without introducing a second config model. These commands stay repo-scoped to the current working directory, and process environment variables still take precedence over repo-local config. QueryWatch-owned files use `managedBy: "qwatch"` as the ownership marker. If an existing `keelmatrix.telemetry.json` is not qwatch-managed, the CLI fails safely instead of overwriting it. + It does not send heartbeat events. Reason: `qwatch` is typically a short-lived CI/local tool, so weekly heartbeat would mostly reflect retained pipeline wiring rather than meaningful interactive product usage. See: diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs new file mode 100644 index 0000000..815c0f1 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/RepoLocalTelemetryConfigInspector.cs @@ -0,0 +1,146 @@ +// Copyright (c) KeelMatrix + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace KeelMatrix.QueryWatch.Cli.Telemetry { + internal static class RepoLocalTelemetryConfigInspector { + internal const string RepositoryConfigFileName = "keelmatrix.telemetry.json"; + internal const string ManagedByPropertyName = "managedBy"; + internal const string ManagedByQwatchValue = "qwatch"; + internal const int MaxRepositoryConfigBytes = 16 * 1024; + + internal static RepoLocalTelemetryConfigInspection Inspect(string repoRoot) { + return InspectPath(Path.Combine(repoRoot, RepositoryConfigFileName)); + } + + internal static RepoLocalTelemetryConfigInspection InspectPath(string path) { + FileInfo fileInfo; + try { + fileInfo = new FileInfo(path); + } + catch { + return RepoLocalTelemetryConfigInspection.Unreadable(path); + } + + try { + if (!fileInfo.Exists) + return RepoLocalTelemetryConfigInspection.Missing(path); + + if (fileInfo.Length > MaxRepositoryConfigBytes) + return RepoLocalTelemetryConfigInspection.TooLarge(path); + + string text = File.ReadAllText(path); + if (text.Length == 0) + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + + JsonNode? node = JsonNode.Parse(text); + if (node is not JsonObject config) + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + + return IsManagedByQueryWatch(config) + ? RepoLocalTelemetryConfigInspection.QueryWatchManaged(path, config) + : RepoLocalTelemetryConfigInspection.NotQueryWatchManaged(path, config); + } + catch (JsonException) { + return RepoLocalTelemetryConfigInspection.InvalidJson(path); + } + catch { + return RepoLocalTelemetryConfigInspection.Unreadable(path); + } + } + + internal static JsonObject CreateNewManagedOptOutConfig() { + return new JsonObject { + ["disabled"] = true, + [ManagedByPropertyName] = ManagedByQwatchValue + }; + } + + internal static void ApplyManagedOptOut(JsonObject config) { + RemovePropertyCaseInsensitive(config, "disabled"); + RemovePropertyCaseInsensitive(config, ManagedByPropertyName); + config["disabled"] = true; + config[ManagedByPropertyName] = ManagedByQwatchValue; + } + + internal static void NormalizeManagedMarker(JsonObject config) { + if (!IsManagedByQueryWatch(config)) + return; + + RemovePropertyCaseInsensitive(config, ManagedByPropertyName); + config[ManagedByPropertyName] = ManagedByQwatchValue; + } + + internal static bool IsManagedByQueryWatch(JsonObject config) { + if (!TryGetPropertyNameCaseInsensitive(config, ManagedByPropertyName, out string? propertyName)) + return false; + + return config[propertyName!] is JsonValue value + && value.TryGetValue(out string? managedBy) + && string.Equals(managedBy, ManagedByQwatchValue, StringComparison.Ordinal); + } + + internal static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) { + KeyValuePair property = obj + .FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (property.Key is not null) { + propertyName = property.Key; + return true; + } + + propertyName = null; + return false; + } + + internal static void RemovePropertyCaseInsensitive(JsonObject obj, string name) { + if (TryGetPropertyNameCaseInsensitive(obj, name, out string? propertyName)) + obj.Remove(propertyName!); + } + } + + internal enum RepoLocalTelemetryConfigStateKind { + Missing = 0, + QueryWatchManaged = 1, + NotQueryWatchManaged = 2, + Unreadable = 3, + InvalidJson = 4, + TooLarge = 5 + } + + internal sealed class RepoLocalTelemetryConfigInspection { + private RepoLocalTelemetryConfigInspection(string path, RepoLocalTelemetryConfigStateKind stateKind, JsonObject? config) { + Path = path; + StateKind = stateKind; + Config = config; + } + + internal string Path { get; } + internal RepoLocalTelemetryConfigStateKind StateKind { get; } + internal JsonObject? Config { get; } + + internal static RepoLocalTelemetryConfigInspection Missing(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Missing, config: null); + } + + internal static RepoLocalTelemetryConfigInspection QueryWatchManaged(string path, JsonObject config) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.QueryWatchManaged, config); + } + + internal static RepoLocalTelemetryConfigInspection NotQueryWatchManaged(string path, JsonObject config) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged, config); + } + + internal static RepoLocalTelemetryConfigInspection Unreadable(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Unreadable, config: null); + } + + internal static RepoLocalTelemetryConfigInspection InvalidJson(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.InvalidJson, config: null); + } + + internal static RepoLocalTelemetryConfigInspection TooLarge(string path) { + return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.TooLarge, config: null); + } + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs new file mode 100644 index 0000000..df71e69 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/Telemetry/TelemetryCommandHandler.cs @@ -0,0 +1,231 @@ +// Copyright (c) KeelMatrix + +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using KeelMatrix.QueryWatch.Cli.Core; +using KeelMatrix.QueryWatch.Cli.Options; +using KeelMatrix.Telemetry; + +namespace KeelMatrix.QueryWatch.Cli.Telemetry { + internal static class TelemetryCommandHandler { + private static readonly JsonSerializerOptions JsonOptions = new() { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public static async Task ExecuteAsync(TelemetryCommandLineOptions options) { + string currentDirectory = Environment.CurrentDirectory; + if (!RepositoryTelemetry.TryResolveRepositoryRoot(currentDirectory, out string repoRoot)) { + await Console.Error.WriteLineAsync( + $"No repository root could be resolved from the current working directory '{currentDirectory}'.") + .ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } + + return options.Command switch { + TelemetryCommandKind.Status => await ExecuteStatusAsync(repoRoot, options.Json).ConfigureAwait(false), + TelemetryCommandKind.Disable => await ExecuteDisableAsync(repoRoot).ConfigureAwait(false), + TelemetryCommandKind.Enable => await ExecuteEnableAsync(repoRoot).ConfigureAwait(false), + _ => ExitCodes.InvalidArguments + }; + } + + private static async Task ExecuteStatusAsync(string repoRoot, bool json) { + TelemetryStatusResult status = GetStatus(repoRoot); + + if (json) { + await Console.Out.WriteLineAsync(JsonSerializer.Serialize(status, JsonOptions)).ConfigureAwait(false); + } + else { + await Console.Out.WriteLineAsync(FormatHumanStatus(status)).ConfigureAwait(false); + } + + return ExitCodes.Ok; + } + + private static async Task ExecuteDisableAsync(string repoRoot) { + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + string? failureMessage = GetDisableFailureMessage(configInspection); + if (failureMessage is not null) { + await Console.Error.WriteLineAsync(failureMessage).ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } + + await WriteDisabledConfigAsync(configInspection).ConfigureAwait(false); + + TelemetryStatusResult status = GetStatus(repoRoot); + await Console.Out.WriteLineAsync( + $"Repo-local qwatch-managed telemetry opt-out written: {configInspection.Path}") + .ConfigureAwait(false); + await WriteEffectiveStatusAsync(status.EffectiveStatus).ConfigureAwait(false); + return ExitCodes.Ok; + } + + private static async Task ExecuteEnableAsync(string repoRoot) { + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + string? failureMessage = GetEnableFailureMessage(configInspection); + if (failureMessage is not null) { + await Console.Error.WriteLineAsync(failureMessage).ConfigureAwait(false); + return ExitCodes.InvalidArguments; + } + + string message = await EnableConfigAsync(configInspection).ConfigureAwait(false); + TelemetryStatusResult status = GetStatus(repoRoot); + + await Console.Out.WriteLineAsync(message).ConfigureAwait(false); + await WriteEffectiveStatusAsync(status.EffectiveStatus).ConfigureAwait(false); + return ExitCodes.Ok; + } + + private static async Task WriteEffectiveStatusAsync(RepositoryTelemetryStatus status) { + await Console.Out.WriteLineAsync($"Telemetry: {(status.IsEnabled ? "enabled" : "disabled")}").ConfigureAwait(false); + await Console.Out.WriteLineAsync($"Source: {FormatSource(status.WinningSourceKind)}").ConfigureAwait(false); + + if (!string.IsNullOrEmpty(status.WinningPath)) + await Console.Out.WriteLineAsync($"Path: {status.WinningPath}").ConfigureAwait(false); + + if (!string.IsNullOrEmpty(status.WinningVariableName)) + await Console.Out.WriteLineAsync($"Variable: {status.WinningVariableName}").ConfigureAwait(false); + } + + private static string FormatHumanStatus(TelemetryStatusResult status) { + RepositoryTelemetryStatus effectiveStatus = status.EffectiveStatus; + StringBuilder sb = new(); + _ = sb.AppendLine($"Telemetry: {(effectiveStatus.IsEnabled ? "enabled" : "disabled")}"); + _ = sb.AppendLine($"Source: {FormatSource(effectiveStatus.WinningSourceKind)}"); + + if (!string.IsNullOrEmpty(effectiveStatus.WinningPath)) + _ = sb.AppendLine($"Path: {effectiveStatus.WinningPath}"); + + if (!string.IsNullOrEmpty(effectiveStatus.WinningVariableName)) + _ = sb.AppendLine($"Variable: {effectiveStatus.WinningVariableName}"); + + _ = sb.AppendLine($"Scope: {FormatScope(effectiveStatus.Scope)}"); + _ = sb.AppendLine($"Repo: {effectiveStatus.RepoRoot}"); + _ = sb.AppendLine($"Repo-local config: {FormatRepoLocalConfigState(status.RepoLocalConfigState)}"); + _ = sb.AppendLine($"Repo-local config path: {status.RepoLocalConfigPath}"); + return sb.ToString().TrimEnd(); + } + + private static TelemetryStatusResult GetStatus(string repoRoot) { + RepoLocalTelemetryConfigInspection configInspection = RepoLocalTelemetryConfigInspector.Inspect(repoRoot); + return new TelemetryStatusResult( + RepositoryTelemetry.GetEffectiveStatus(repoRoot), + configInspection.StateKind, + configInspection.Path); + } + + private static async Task WriteDisabledConfigAsync(RepoLocalTelemetryConfigInspection configInspection) { + JsonObject config = configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.Missing => RepoLocalTelemetryConfigInspector.CreateNewManagedOptOutConfig(), + RepoLocalTelemetryConfigStateKind.QueryWatchManaged => configInspection.Config!.DeepClone().AsObject(), + _ => throw new InvalidOperationException("Disable should only write when the config is missing or qwatch-managed.") + }; + + RepoLocalTelemetryConfigInspector.ApplyManagedOptOut(config); + + string json = JsonSerializer.Serialize(config, JsonOptions); + await File.WriteAllTextAsync(configInspection.Path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + } + + private static async Task EnableConfigAsync(RepoLocalTelemetryConfigInspection configInspection) { + if (configInspection.StateKind == RepoLocalTelemetryConfigStateKind.Missing) + return $"No repo-local telemetry config exists: {configInspection.Path}"; + + JsonObject config = configInspection.Config!.DeepClone().AsObject(); + RepoLocalTelemetryConfigInspector.NormalizeManagedMarker(config); + bool hadDisabled = RepoLocalTelemetryConfigInspector.TryGetPropertyNameCaseInsensitive(config, "disabled", out string? propertyName); + if (hadDisabled) + config.Remove(propertyName!); + + if (hadDisabled && HasOnlyQueryWatchManagedMarker(config)) { + File.Delete(configInspection.Path); + return $"Repo-local qwatch-managed telemetry opt-out removed: {configInspection.Path}"; + } + + if (!hadDisabled) + return $"No qwatch-managed repo-local telemetry opt-out was found: {configInspection.Path}"; + + string json = JsonSerializer.Serialize(config, JsonOptions); + await File.WriteAllTextAsync(configInspection.Path, json + Environment.NewLine, Encoding.UTF8).ConfigureAwait(false); + return $"Repo-local qwatch-managed telemetry config updated: {configInspection.Path}"; + } + + private static string? GetDisableFailureMessage(RepoLocalTelemetryConfigInspection configInspection) { + return configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => + $"Refusing to overwrite existing repo-local telemetry config because it is not qwatch-managed: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.Unreadable => + $"Refusing to overwrite existing repo-local telemetry config because it could not be read: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.InvalidJson => + $"Refusing to overwrite existing repo-local telemetry config because it contains invalid JSON: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.TooLarge => + $"Refusing to overwrite existing repo-local telemetry config because it exceeds the {RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes}-byte inspection limit: {configInspection.Path}", + _ => null + }; + } + + private static string? GetEnableFailureMessage(RepoLocalTelemetryConfigInspection configInspection) { + return configInspection.StateKind switch { + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => + $"Existing repo-local telemetry config is not qwatch-managed and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.Unreadable => + $"Existing repo-local telemetry config could not be read and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.InvalidJson => + $"Existing repo-local telemetry config contains invalid JSON and was left unchanged: {configInspection.Path}", + RepoLocalTelemetryConfigStateKind.TooLarge => + $"Existing repo-local telemetry config exceeds the {RepoLocalTelemetryConfigInspector.MaxRepositoryConfigBytes}-byte inspection limit and was left unchanged: {configInspection.Path}", + _ => null + }; + } + + private static bool HasOnlyQueryWatchManagedMarker(JsonObject config) { + return config.Count == 1 && RepoLocalTelemetryConfigInspector.IsManagedByQueryWatch(config); + } + + private static string FormatSource(RepositoryTelemetrySourceKind sourceKind) { + return sourceKind switch { + RepositoryTelemetrySourceKind.None => "none", + RepositoryTelemetrySourceKind.ProcessEnvironment => "process environment", + RepositoryTelemetrySourceKind.RepositoryConfig => RepoLocalTelemetryConfigInspector.RepositoryConfigFileName, + RepositoryTelemetrySourceKind.DotEnvLocal => ".env.local", + RepositoryTelemetrySourceKind.DotEnv => ".env", + _ => "none" + }; + } + + private static string FormatScope(RepositoryTelemetryScope scope) { + return scope switch { + RepositoryTelemetryScope.RepoLocalDefault => "repo-local default", + RepositoryTelemetryScope.ProcessEnvironment => "process-level", + RepositoryTelemetryScope.RepoLocal => "repo-local", + _ => "repo-local default" + }; + } + + private static string FormatRepoLocalConfigState(RepoLocalTelemetryConfigStateKind stateKind) { + return stateKind switch { + RepoLocalTelemetryConfigStateKind.Missing => "missing", + RepoLocalTelemetryConfigStateKind.QueryWatchManaged => "qwatch-managed", + RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged => "not qwatch-managed", + RepoLocalTelemetryConfigStateKind.Unreadable => "unreadable", + RepoLocalTelemetryConfigStateKind.InvalidJson => "invalid JSON", + RepoLocalTelemetryConfigStateKind.TooLarge => "exceeds inspection size cap", + _ => "missing" + }; + } + } + + internal sealed class TelemetryStatusResult( + RepositoryTelemetryStatus effectiveStatus, + RepoLocalTelemetryConfigStateKind repoLocalConfigState, + string repoLocalConfigPath) { + public RepositoryTelemetryStatus EffectiveStatus { get; } = effectiveStatus; + public RepoLocalTelemetryConfigStateKind RepoLocalConfigState { get; } = repoLocalConfigState; + public string RepoLocalConfigPath { get; } = repoLocalConfigPath; + } +} diff --git a/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs b/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs new file mode 100644 index 0000000..a0d1886 --- /dev/null +++ b/tools/KeelMatrix.QueryWatch.Cli/TelemetryHost.cs @@ -0,0 +1,30 @@ +// Copyright (c) KeelMatrix + +using KeelMatrix.Telemetry; + +namespace KeelMatrix.QueryWatch.Cli { + internal interface ITelemetryHost { + void TrackActivation(); + } + + internal sealed class TelemetryHostClient : ITelemetryHost { + private static readonly Client Client = new("qwatchCLI", typeof(Program)); + + public void TrackActivation() { + Client.TrackActivation(); + } + } + + internal static class TelemetryHost { + private static ITelemetryHost current = new TelemetryHostClient(); + + internal static void TrackActivation() { + Volatile.Read(ref current).TrackActivation(); + } + + internal static ITelemetryHost Current { + get => Volatile.Read(ref current); + set => Volatile.Write(ref current, value ?? throw new ArgumentNullException(nameof(value))); + } + } +}