Skip to content

Commit 938f531

Browse files
committed
Tighten CLI telemetry ownership handling
1 parent 2b212a9 commit 938f531

12 files changed

Lines changed: 612 additions & 223 deletions

File tree

PRIVACY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,10 @@ QueryWatch packages use the shared telemetry package, but not every package uses
2121

2222
- The main `KeelMatrix.QueryWatch` library uses activation and heartbeat through its session lifecycle.
2323
- The `qwatch` CLI sends an activation event on normal execution, but does not send heartbeat events.
24+
- `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` do not emit telemetry.
25+
- `qwatch telemetry disable` writes a repo-local opt-out only when it can safely create or update a qwatch-managed config.
26+
- `qwatch telemetry enable` only removes or neutralizes qwatch-managed repo-local opt-out state.
27+
- QueryWatch-owned repo-local configs use `managedBy: "qwatch"` as the ownership marker.
28+
- Higher-precedence process environment variables still override repo-local config.
2429

2530
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.

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,8 @@ Usage:
218218
qwatch telemetry <status|disable|enable> [options]
219219
220220
Commands:
221-
telemetry status [--json] Show effective telemetry state for the current repo.
222-
telemetry disable Write repo-local keelmatrix.telemetry.json with disabled=true.
221+
telemetry status [--json] Show effective telemetry state and repo-local config status for the current repo.
222+
telemetry disable Write a qwatch-managed repo-local telemetry opt-out.
223223
telemetry enable Remove or neutralize qwatch-managed repo-local telemetry opt-out.
224224
225225
Options:
@@ -230,7 +230,7 @@ Options:
230230
--baseline <path> Baseline summary JSON to compare against.
231231
--baseline-allow-percent P Allow +P% regression vs baseline before failing.
232232
--write-baseline Write current aggregated summary to --baseline.
233-
--budget "<pattern>=<max>" Per-pattern query count budget (repeatable). (repeatable)
233+
--budget "<pattern>=<max>" Per-pattern query count budget. (repeatable)
234234
Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex.
235235
--require-full-events Fail if input summaries are top-N sampled.
236236
--help Show this help.
@@ -242,7 +242,7 @@ Multi-file support:
242242
- repeat `--input` to aggregate summaries from multiple test projects
243243
- compare current results against a baseline summary
244244
- write GitHub Actions step summaries automatically when running in CI
245-
- inspect or manage repo-local telemetry with `qwatch telemetry status|disable|enable`
245+
- inspect or manage repo-local telemetry opt-out state with `qwatch telemetry status|disable|enable`
246246

247247
## Troubleshooting
248248

@@ -260,6 +260,8 @@ See:
260260
- [PRIVACY.md](PRIVACY.md) for the QueryWatch-specific summary
261261
- [KeelMatrix.Telemetry README](https://github.com/KeelMatrix/Telemetry#readme) for the maintained telemetry behavior and opt-out details
262262

263+
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.
264+
263265
## License
264266

265267
MIT

src/KeelMatrix.QueryWatch/Internal/QueryWatchTelemetry.cs renamed to src/KeelMatrix.QueryWatch/Internal/TelemetryHost.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
using KeelMatrix.Telemetry;
44

55
namespace KeelMatrix.QueryWatch {
6-
internal static class QueryWatchTelemetry {
6+
internal static class TelemetryHost {
77
private static readonly Client Client = new("QueryWatch", typeof(QueryWatchSession));
88

99
internal static void TrackActivation() => Client.TrackActivation();

src/KeelMatrix.QueryWatch/QueryWatchSession.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public QueryWatchSession(QueryWatchOptions? options = null) {
3131
Options = options ?? new QueryWatchOptions();
3232
StartedAt = DateTimeOffset.UtcNow;
3333

34-
QueryWatchTelemetry.TrackActivation();
34+
TelemetryHost.TrackActivation();
3535
}
3636

3737
/// <summary>Options for this session.</summary>
@@ -131,7 +131,7 @@ private QueryWatchReport StopInternal() {
131131

132132
if (Interlocked.CompareExchange(ref _stopped, 1, 0) == 0) {
133133
StoppedAt = now;
134-
QueryWatchTelemetry.TrackHeartbeat();
134+
TelemetryHost.TrackHeartbeat();
135135
}
136136

137137
List<QueryEvent> snapshot;

tests/KeelMatrix.QueryWatch.Cli.IntegrationTests/TelemetryCommandTests.cs

Lines changed: 279 additions & 30 deletions
Large diffs are not rendered by default.

tools/KeelMatrix.QueryWatch.Cli/Options/CliSpec.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ internal static class CliSpec {
1515
new CliOption("--baseline", "<path>", "Baseline summary JSON to compare against."),
1616
new CliOption("--baseline-allow-percent", "P", "Allow +P% regression vs baseline before failing."),
1717
new CliOption("--write-baseline", null, "Write current aggregated summary to --baseline."),
18-
new CliOption("--budget", "\"<pattern>=<max>\"", "Per-pattern query count budget (repeatable).",
18+
new CliOption("--budget", "\"<pattern>=<max>\"", "Per-pattern query count budget.",
1919
Notes: "Pattern supports wildcards (*, ?) or prefix with 'regex:' for raw regex.", Repeatable: true),
2020
new CliOption("--require-full-events", null, "Fail if input summaries are top-N sampled."),
2121
new CliOption("--help", null, "Show this help.")
2222
];
2323

2424
public static readonly CliCommand[] TelemetryCommands = [
25-
new CliCommand("telemetry status [--json]", "Show effective telemetry state for the current repo."),
26-
new CliCommand("telemetry disable", "Write repo-local keelmatrix.telemetry.json with disabled=true."),
25+
new CliCommand("telemetry status [--json]", "Show effective telemetry state and repo-local config status for the current repo."),
26+
new CliCommand("telemetry disable", "Write a qwatch-managed repo-local telemetry opt-out."),
2727
new CliCommand("telemetry enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out.")
2828
];
2929

@@ -66,8 +66,8 @@ public static string BuildTelemetryHelpText() {
6666
_ = sb.AppendLine(" qwatch telemetry enable");
6767
_ = sb.AppendLine();
6868
_ = sb.AppendLine("Commands:");
69-
_ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state for the current repo.");
70-
_ = AppendAlignedLine(sb, leftWidth, "disable", "Write repo-local keelmatrix.telemetry.json with disabled=true.");
69+
_ = AppendAlignedLine(sb, leftWidth, "status [--json]", "Show effective telemetry state and repo-local config status for the current repo.");
70+
_ = AppendAlignedLine(sb, leftWidth, "disable", "Write a qwatch-managed repo-local telemetry opt-out.");
7171
_ = AppendAlignedLine(sb, leftWidth, "enable", "Remove or neutralize qwatch-managed repo-local telemetry opt-out.");
7272
_ = sb.AppendLine();
7373
_ = sb.AppendLine("Options:");

tools/KeelMatrix.QueryWatch.Cli/Program.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
namespace KeelMatrix.QueryWatch.Cli {
88
internal static class Program {
9-
private static async Task<int> Main(string[] args) {
9+
private static Task<int> Main(string[] args) {
10+
return RunAsync(args);
11+
}
12+
13+
internal static async Task<int> RunAsync(string[] args) {
1014
// 0) No arguments → show help
1115
if (args.Length == 0) {
1216
Console.WriteLine(CommandLineOptions.HelpText);
@@ -42,7 +46,7 @@ private static async Task<int> Main(string[] args) {
4246
return await TelemetryCommandHandler.ExecuteAsync(parsed.TelemetryOptions).ConfigureAwait(false);
4347

4448
// 4) Normal execution
45-
QueryWatchCliTelemetry.TrackActivation();
49+
TelemetryHost.TrackActivation();
4650
return await Runner.ExecuteAsync(parsed.Options!).ConfigureAwait(false);
4751
}
4852
}

tools/KeelMatrix.QueryWatch.Cli/QueryWatchCliTelemetry.cs

Lines changed: 0 additions & 28 deletions
This file was deleted.

tools/KeelMatrix.QueryWatch.Cli/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@ Show help:
3131
qwatch --help
3232
```
3333

34-
Inspect telemetry state for the current repo:
34+
Inspect effective telemetry state and repo-local config status for the current repo:
3535

3636
```bash
3737
qwatch telemetry status
3838
```
3939

40-
Disable telemetry for the current repo:
40+
Write a qwatch-managed repo-local telemetry opt-out for the current repo:
4141

4242
```bash
4343
qwatch telemetry disable
4444
```
4545

46-
Re-enable telemetry for the current repo:
46+
Remove a qwatch-managed repo-local telemetry opt-out for the current repo:
4747

4848
```bash
4949
qwatch telemetry enable
@@ -155,7 +155,7 @@ If you only need the file format in another tool, see the contracts package:
155155

156156
`qwatch` sends a minimal anonymous telemetry activation event on normal CLI execution.
157157

158-
Telemetry management commands do not emit telemetry. Use `qwatch telemetry status`, `qwatch telemetry disable`, and `qwatch telemetry enable` to inspect or manage repo-local telemetry behavior without introducing a second config model.
158+
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.
159159

160160
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.
161161

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) KeelMatrix
2+
3+
using System.Text.Json;
4+
using System.Text.Json.Nodes;
5+
6+
namespace KeelMatrix.QueryWatch.Cli.Telemetry {
7+
internal static class RepoLocalTelemetryConfigInspector {
8+
internal const string RepositoryConfigFileName = "keelmatrix.telemetry.json";
9+
internal const string ManagedByPropertyName = "managedBy";
10+
internal const string ManagedByQwatchValue = "qwatch";
11+
internal const int MaxRepositoryConfigBytes = 16 * 1024;
12+
13+
private static readonly IRepoLocalTelemetryConfigFileAccess DefaultFileAccess = new RepoLocalTelemetryConfigFileAccess();
14+
15+
internal static RepoLocalTelemetryConfigInspection Inspect(string repoRoot) {
16+
return InspectPath(Path.Combine(repoRoot, RepositoryConfigFileName));
17+
}
18+
19+
internal static RepoLocalTelemetryConfigInspection InspectPath(string path) {
20+
return InspectPath(path, DefaultFileAccess);
21+
}
22+
23+
internal static RepoLocalTelemetryConfigInspection InspectPath(string path, IRepoLocalTelemetryConfigFileAccess fileAccess) {
24+
try {
25+
RepoLocalTelemetryConfigFileMetadata fileMetadata = fileAccess.GetMetadata(path);
26+
if (!fileMetadata.Exists)
27+
return RepoLocalTelemetryConfigInspection.Missing(path);
28+
29+
if (fileMetadata.Length > MaxRepositoryConfigBytes)
30+
return RepoLocalTelemetryConfigInspection.TooLarge(path);
31+
32+
string text = fileAccess.ReadAllText(path);
33+
if (text.Length == 0)
34+
return RepoLocalTelemetryConfigInspection.InvalidJson(path);
35+
36+
JsonNode? node = JsonNode.Parse(text);
37+
if (node is not JsonObject config)
38+
return RepoLocalTelemetryConfigInspection.InvalidJson(path);
39+
40+
return IsManagedByQueryWatch(config)
41+
? RepoLocalTelemetryConfigInspection.QueryWatchManaged(path, config)
42+
: RepoLocalTelemetryConfigInspection.NotQueryWatchManaged(path, config);
43+
}
44+
catch (JsonException) {
45+
return RepoLocalTelemetryConfigInspection.InvalidJson(path);
46+
}
47+
catch {
48+
return RepoLocalTelemetryConfigInspection.Unreadable(path);
49+
}
50+
}
51+
52+
internal static JsonObject CreateNewManagedOptOutConfig() {
53+
return new JsonObject {
54+
["disabled"] = true,
55+
[ManagedByPropertyName] = ManagedByQwatchValue
56+
};
57+
}
58+
59+
internal static void ApplyManagedOptOut(JsonObject config) {
60+
RemovePropertyCaseInsensitive(config, "disabled");
61+
RemovePropertyCaseInsensitive(config, ManagedByPropertyName);
62+
config["disabled"] = true;
63+
config[ManagedByPropertyName] = ManagedByQwatchValue;
64+
}
65+
66+
internal static void NormalizeManagedMarker(JsonObject config) {
67+
if (!IsManagedByQueryWatch(config))
68+
return;
69+
70+
RemovePropertyCaseInsensitive(config, ManagedByPropertyName);
71+
config[ManagedByPropertyName] = ManagedByQwatchValue;
72+
}
73+
74+
internal static bool IsManagedByQueryWatch(JsonObject config) {
75+
if (!TryGetPropertyNameCaseInsensitive(config, ManagedByPropertyName, out string? propertyName))
76+
return false;
77+
78+
return config[propertyName!] is JsonValue value
79+
&& value.TryGetValue(out string? managedBy)
80+
&& string.Equals(managedBy, ManagedByQwatchValue, StringComparison.Ordinal);
81+
}
82+
83+
internal static bool TryGetPropertyNameCaseInsensitive(JsonObject obj, string name, out string? propertyName) {
84+
KeyValuePair<string, JsonNode?> property = obj
85+
.FirstOrDefault(property => property.Key.Equals(name, StringComparison.OrdinalIgnoreCase));
86+
if (property.Key is not null) {
87+
propertyName = property.Key;
88+
return true;
89+
}
90+
91+
propertyName = null;
92+
return false;
93+
}
94+
95+
internal static void RemovePropertyCaseInsensitive(JsonObject obj, string name) {
96+
if (TryGetPropertyNameCaseInsensitive(obj, name, out string? propertyName))
97+
obj.Remove(propertyName!);
98+
}
99+
}
100+
101+
internal interface IRepoLocalTelemetryConfigFileAccess {
102+
RepoLocalTelemetryConfigFileMetadata GetMetadata(string path);
103+
string ReadAllText(string path);
104+
}
105+
106+
internal readonly record struct RepoLocalTelemetryConfigFileMetadata(bool Exists, long Length);
107+
108+
internal sealed class RepoLocalTelemetryConfigFileAccess : IRepoLocalTelemetryConfigFileAccess {
109+
public RepoLocalTelemetryConfigFileMetadata GetMetadata(string path) {
110+
FileInfo fileInfo = new(path);
111+
return new RepoLocalTelemetryConfigFileMetadata(fileInfo.Exists, fileInfo.Exists ? fileInfo.Length : 0);
112+
}
113+
114+
public string ReadAllText(string path) {
115+
return File.ReadAllText(path);
116+
}
117+
}
118+
119+
internal enum RepoLocalTelemetryConfigStateKind {
120+
Missing = 0,
121+
QueryWatchManaged = 1,
122+
NotQueryWatchManaged = 2,
123+
Unreadable = 3,
124+
InvalidJson = 4,
125+
TooLarge = 5
126+
}
127+
128+
internal sealed class RepoLocalTelemetryConfigInspection {
129+
private RepoLocalTelemetryConfigInspection(string path, RepoLocalTelemetryConfigStateKind stateKind, JsonObject? config) {
130+
Path = path;
131+
StateKind = stateKind;
132+
Config = config;
133+
}
134+
135+
internal string Path { get; }
136+
internal RepoLocalTelemetryConfigStateKind StateKind { get; }
137+
internal JsonObject? Config { get; }
138+
139+
internal static RepoLocalTelemetryConfigInspection Missing(string path) {
140+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Missing, config: null);
141+
}
142+
143+
internal static RepoLocalTelemetryConfigInspection QueryWatchManaged(string path, JsonObject config) {
144+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.QueryWatchManaged, config);
145+
}
146+
147+
internal static RepoLocalTelemetryConfigInspection NotQueryWatchManaged(string path, JsonObject config) {
148+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.NotQueryWatchManaged, config);
149+
}
150+
151+
internal static RepoLocalTelemetryConfigInspection Unreadable(string path) {
152+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.Unreadable, config: null);
153+
}
154+
155+
internal static RepoLocalTelemetryConfigInspection InvalidJson(string path) {
156+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.InvalidJson, config: null);
157+
}
158+
159+
internal static RepoLocalTelemetryConfigInspection TooLarge(string path) {
160+
return new RepoLocalTelemetryConfigInspection(path, RepoLocalTelemetryConfigStateKind.TooLarge, config: null);
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)