diff --git a/build/build/BuildLifetime.cs b/build/build/BuildLifetime.cs index bf52c91af8..85a7b65810 100644 --- a/build/build/BuildLifetime.cs +++ b/build/build/BuildLifetime.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Build.Utilities; using Common.Lifetime; using Common.Utilities; @@ -46,5 +47,13 @@ private static void SetMsBuildSettingsVersion(BuildContext context) // https://github.com/dotnet/docs/issues/37674 msBuildSettings.WithProperty("IncludeSourceRevisionInInformationalVersion", "false"); + + if (ShouldEmbedReleaseDate(context)) + { + msBuildSettings.WithProperty("GitVersionReleaseDate", DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } } + + private static bool ShouldEmbedReleaseDate(BuildContext context) => + context.IsTaggedRelease || context.IsTaggedPreRelease || context.IsInternalPreRelease; } diff --git a/docs/input/docs/usage/cli/arguments.md b/docs/input/docs/usage/cli/arguments.md index 8198325495..64563d64a8 100644 --- a/docs/input/docs/usage/cli/arguments.md +++ b/docs/input/docs/usage/cli/arguments.md @@ -52,10 +52,12 @@ GitVersion [path] --show-config Outputs the effective GitVersion config (defaults + custom from GitVersion.yml, GitVersion.yaml, .GitVersion.yml or .GitVersion.yaml) in yaml format --override-config - Overrides GitVersion config values inline (semicolon- - separated key value pairs e.g. --override-config - tag-prefix=Foo) - Currently supported config overrides: tag-prefix + Overrides GitVersion config values inline (semicolon- + separated key value pairs e.g. --override-config + tag-prefix=Foo) + Currently supported config overrides: tag-prefix + --telemetry-opt-out + Disables telemetry for this invocation. --no-cache Bypasses the cache, result will not be written to the cache. --no-normalize Disables normalize step on a build server. --allow-shallow Allows GitVersion to run on a shallow clone. @@ -170,3 +172,13 @@ Will use only major and minor version numbers for assembly version. Assembly bui ### Example: How to override configuration option 'next-version' `GitVersion.exe --output json --override-config next-version=6` + +## Telemetry + +GitVersion supports OSS-style telemetry opt-out controls: + +1. `DO_NOT_TRACK` +2. `GITVERSION_TELEMETRY_OPTOUT` +3. `--telemetry-opt-out` + +See [CLI Telemetry](/docs/usage/cli/telemetry) for the full disclosure, collected fields, redaction rules, reasons for collection, and opt-out behavior. diff --git a/docs/input/docs/usage/cli/telemetry.md b/docs/input/docs/usage/cli/telemetry.md new file mode 100644 index 0000000000..bc3b3110f4 --- /dev/null +++ b/docs/input/docs/usage/cli/telemetry.md @@ -0,0 +1,102 @@ +--- +Order: 25 +Title: Telemetry +Description: Telemetry behavior, collected fields, redaction rules, and opt-out for the GitVersion CLI +--- + +GitVersion can support an optional telemetry pipeline for limited OSS data-collection windows when a telemetry sink is enabled in the distributed build. The purpose is to help maintainers answer product questions such as: + +1. Which CLI arguments are used most often +2. Which parser implementation is still in active use +3. Which GitVersion versions are represented in incoming usage data +4. Which command-line flows should be prioritized for UX and documentation improvements + +## When telemetry is active + +Telemetry is only emitted when a concrete telemetry sink is enabled for the GitVersion distribution you are running. + +When telemetry is active, GitVersion shows a one-time disclosure notice before the first telemetry-eligible command sends data. The notice is stored locally so it is not shown on every run. + +Telemetry is also timeboxed to the first **3 months after the release date embedded in the assembly metadata**. If the release-date metadata is missing or invalid, telemetry is treated as disabled. + +## What GitVersion collects + +The telemetry payload is intentionally narrow and command-line focused. It is designed to support usage analysis, not repository inspection. + +Each payload includes: + +| Field | Description | Reason | +| --- | --- | --- | +| `toolVersion` | The GitVersion CLI version | Helps correlate behavior with a released version | +| `parserImplementation` | `ArgumentParser` or `LegacyArgumentParser` | Helps understand adoption of the legacy parser | +| `continuousIntegrationProvider` | The detected CI provider such as `github-actions`, or `unknown` when GitVersion is not running in a recognized CI environment | Helps understand where CLI usage comes from without collecting repository data | +| `invocationSource` | `gitversion-msbuild` when the CLI is invoked internally by the `GitVersion.MsBuild` package, otherwise `direct` | Helps distinguish direct CLI usage from package-driven usage | +| `command` | The CLI command name (`gitversion`) | Identifies the invoked entry point | +| `subcommand` | Reserved for future CLI command trees; currently `null` in the stable CLI | Keeps the payload shape stable | +| `arguments[].name` | Canonical argument name | Shows which switches/options are used | +| `arguments[].values` | Argument values when allowed | Shows which non-sensitive modes and options are used | + +## Argument value rules + +GitVersion keeps argument names but applies redaction rules to values when they may contain file-system or sensitive information. + +### Collected as plain values + +Examples include: + +- `output` +- `show-variable` +- `format` +- `verbosity` +- `branch` +- `commit` +- `override-config` + +### Redacted as path values + +These arguments keep their names, but their values are replaced with ``: + +- `path` +- `target-path` +- `log-file` +- `config` +- `dynamic-repo-location` +- `update-assembly-info` +- `update-project-files` + +For path-oriented switches that accept boolean forms, explicit boolean values such as `true`, `false`, `1`, or `0` are kept. Non-boolean path values are redacted. + +### Redacted as sensitive values + +These arguments keep their names, but their values are replaced with ``: + +- `url` +- `username` +- `password` + +## What GitVersion does not collect + +GitVersion telemetry is not intended to inspect repositories or build artifacts. In particular, it does not collect: + +- repository contents +- commit messages +- branch history +- configuration file contents +- generated version variables +- file contents +- raw path values +- raw credential values + +## Opting out + +You can disable telemetry in any of these ways: + +1. Set `DO_NOT_TRACK=1` or `DO_NOT_TRACK=true` +2. Set `GITVERSION_TELEMETRY_OPTOUT=1` or `GITVERSION_TELEMETRY_OPTOUT=true` +3. Pass `--telemetry-opt-out` for a single invocation + +If any of these opt-out mechanisms are used, GitVersion treats telemetry as disabled for that invocation. + +## Collection windows + +Telemetry is intended for time-boxed collection windows to support OSS design decisions. A distribution can enable telemetry for a limited period, disable it, and later enable it again for a new decision-making window. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 615bf67883..e9156102b3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -58,6 +58,13 @@ true + + + <_Parameter1>GitVersionReleaseDate + <_Parameter2>$(GitVersionReleaseDate) + + + all diff --git a/src/GitVersion.App.Tests/ArgumentParserTests.cs b/src/GitVersion.App.Tests/ArgumentParserTests.cs index 382f7dab39..17c301cecb 100644 --- a/src/GitVersion.App.Tests/ArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/ArgumentParserTests.cs @@ -104,6 +104,51 @@ public void UsernameAndPasswordCanBeParsed() arguments.IsHelp.ShouldBe(false); } + [Test] + public void TelemetryOptOutCanBeParsed() + { + var arguments = this.argumentParser.ParseArguments("--telemetry-opt-out"); + arguments.TelemetryOptOut.ShouldBe(true); + } + + [Test] + public void TelemetryOptOutCanBeProvidedByEnvironment() + { + this.environment.SetEnvironmentVariable(TelemetryPolicy.DoNotTrackEnvironmentVariable, "1"); + + var arguments = this.argumentParser.ParseArguments("targetDirectoryPath"); + + arguments.TelemetryOptOut.ShouldBe(true); + } + + [Test] + public void TelemetryPayloadIncludesParserVersionAndRedactedArguments() + { + var arguments = this.argumentParser.ParseArguments( + "targetDirectoryPath --log-file console --output buildserver --format {SemVer} --url https://example.com/repo.git --username user --password pass --dynamic-repo-location /tmp/dynamic"); + + arguments.Telemetry.ShouldNotBeNull(); + arguments.Telemetry.ParserImplementation.ShouldBe(nameof(ArgumentParser)); + arguments.Telemetry.Command.ShouldBe("gitversion"); + arguments.Telemetry.ToolVersion.ShouldNotBeNullOrWhiteSpace(); + arguments.Telemetry.ContinuousIntegrationProvider.ShouldBe(TelemetryContextValues.Unknown); + arguments.Telemetry.InvocationSource.ShouldBe(TelemetryContextValues.Direct); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Path && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.LogFile && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Output && argument.Values.Single() == "buildserver"); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Url && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Username && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Password && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.DynamicRepoLocation && argument.Values.Single() == ""); + } + [Test] public void UnknownOutputShouldThrow() { diff --git a/src/GitVersion.App.Tests/HelpWriterTests.cs b/src/GitVersion.App.Tests/HelpWriterTests.cs index 0b1a6e26e2..461f6fd65f 100644 --- a/src/GitVersion.App.Tests/HelpWriterTests.cs +++ b/src/GitVersion.App.Tests/HelpWriterTests.cs @@ -51,6 +51,7 @@ public void AllArgsAreInHelp() var helpText = string.Empty; this.helpWriter.WriteTo(s => helpText = s); + helpText.ShouldContain("/telemetryoptout"); var ignored = new[] { diff --git a/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs b/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs index 37292ca023..db697cd10b 100644 --- a/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs +++ b/src/GitVersion.App.Tests/LegacyArgumentParserTests.cs @@ -94,6 +94,51 @@ public void UsernameAndPasswordCanBeParsed() arguments.IsHelp.ShouldBe(false); } + [Test] + public void TelemetryOptOutCanBeParsed() + { + var arguments = this.argumentParser.ParseArguments("/telemetryoptout"); + arguments.TelemetryOptOut.ShouldBe(true); + } + + [Test] + public void TelemetryOptOutCanBeProvidedByEnvironment() + { + this.environment.SetEnvironmentVariable(TelemetryPolicy.GitVersionTelemetryOptOutEnvironmentVariable, "true"); + + var arguments = this.argumentParser.ParseArguments("targetDirectoryPath"); + + arguments.TelemetryOptOut.ShouldBe(true); + } + + [Test] + public void TelemetryPayloadIncludesParserVersionAndRedactedArguments() + { + var arguments = this.argumentParser.ParseArguments( + "targetDirectoryPath /l console /output buildserver /format {SemVer} /url https://example.com/repo.git /u user /p pass /dynamicRepoLocation /tmp/dynamic"); + + arguments.Telemetry.ShouldNotBeNull(); + arguments.Telemetry.ParserImplementation.ShouldBe(nameof(LegacyArgumentParser)); + arguments.Telemetry.Command.ShouldBe("gitversion"); + arguments.Telemetry.ToolVersion.ShouldNotBeNullOrWhiteSpace(); + arguments.Telemetry.ContinuousIntegrationProvider.ShouldBe(TelemetryContextValues.Unknown); + arguments.Telemetry.InvocationSource.ShouldBe(TelemetryContextValues.Direct); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Path && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.LogFile && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Output && argument.Values.Single() == "buildserver"); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Url && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Username && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.Password && argument.Values.Single() == ""); + arguments.Telemetry.Arguments.ShouldContain(argument => + argument.Name == TelemetryArgumentNames.DynamicRepoLocation && argument.Values.Single() == ""); + } + [Test] public void UnknownOutputShouldThrow() { diff --git a/src/GitVersion.App.Tests/TelemetryContextEnricherTests.cs b/src/GitVersion.App.Tests/TelemetryContextEnricherTests.cs new file mode 100644 index 0000000000..94d5ea051d --- /dev/null +++ b/src/GitVersion.App.Tests/TelemetryContextEnricherTests.cs @@ -0,0 +1,106 @@ +using GitVersion.OutputVariables; + +namespace GitVersion.App.Tests; + +[TestFixture] +public class TelemetryContextEnricherTests +{ + [Test] + public void EnrichMapsSupportedContinuousIntegrationProviders() + { + var enricher = new TelemetryContextEnricher(new GitHubActions(), new TestEnvironment()); + + var telemetry = enricher.Enrich(CreateTelemetry()); + + telemetry.ContinuousIntegrationProvider.ShouldBe("github-actions"); + telemetry.InvocationSource.ShouldBe(TelemetryContextValues.Direct); + } + + [Test] + public void EnrichDetectsGitVersionMsBuildInvocations() + { + var environment = new TestEnvironment(); + environment.SetEnvironmentVariable(TelemetryContextValues.InternalCallerEnvironmentVariable, "GitVersion.MsBuild"); + var enricher = new TelemetryContextEnricher(new LocalBuild(), environment); + + var telemetry = enricher.Enrich(CreateTelemetry()); + + telemetry.ContinuousIntegrationProvider.ShouldBe(TelemetryContextValues.Unknown); + telemetry.InvocationSource.ShouldBe(TelemetryContextValues.GitVersionMsBuild); + } + + [Test] + public void EnrichDefaultsInvocationSourceToDirectForUnsupportedContext() + { + var enricher = new TelemetryContextEnricher(new UnsupportedBuildAgent(), new TestEnvironment()); + + var telemetry = enricher.Enrich(CreateTelemetry()); + + telemetry.ContinuousIntegrationProvider.ShouldBe(TelemetryContextValues.Unknown); + telemetry.InvocationSource.ShouldBe(TelemetryContextValues.Direct); + } + + private static CommandLineTelemetry CreateTelemetry() => new( + "1.2.3", + nameof(ArgumentParser), + TelemetryContextValues.Unknown, + TelemetryContextValues.Direct, + "gitversion", + null, + []); + + private sealed class TestEnvironment : IEnvironment + { + private readonly Dictionary variables = new(StringComparer.Ordinal); + + public string? GetEnvironmentVariable(string variableName) => + this.variables.TryGetValue(variableName, out var value) ? value : null; + + public void SetEnvironmentVariable(string variableName, string? value) + { + if (value == null) + { + this.variables.Remove(variableName); + return; + } + + this.variables[variableName] = value; + } + } + + private sealed class GitHubActions : Agents.ICurrentBuildAgent + { + public bool IsDefault => false; + public bool CanApplyToCurrentContext() => true; + public string? GetCurrentBranch(bool usingDynamicRepos) => null; + public bool PreventFetch() => true; + public bool ShouldCleanUpRemotes() => false; + public void WriteIntegration(Action writer, GitVersionVariables variables, bool updateBuildNumber = true) + { + } + } + + private sealed class LocalBuild : Agents.ICurrentBuildAgent + { + public bool IsDefault => true; + public bool CanApplyToCurrentContext() => true; + public string? GetCurrentBranch(bool usingDynamicRepos) => null; + public bool PreventFetch() => true; + public bool ShouldCleanUpRemotes() => false; + public void WriteIntegration(Action writer, GitVersionVariables variables, bool updateBuildNumber = true) + { + } + } + + private sealed class UnsupportedBuildAgent : Agents.ICurrentBuildAgent + { + public bool IsDefault => false; + public bool CanApplyToCurrentContext() => true; + public string? GetCurrentBranch(bool usingDynamicRepos) => null; + public bool PreventFetch() => true; + public bool ShouldCleanUpRemotes() => false; + public void WriteIntegration(Action writer, GitVersionVariables variables, bool updateBuildNumber = true) + { + } + } +} diff --git a/src/GitVersion.App.Tests/TelemetryReleaseDateTests.cs b/src/GitVersion.App.Tests/TelemetryReleaseDateTests.cs new file mode 100644 index 0000000000..f802a74920 --- /dev/null +++ b/src/GitVersion.App.Tests/TelemetryReleaseDateTests.cs @@ -0,0 +1,62 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace GitVersion.App.Tests; + +[TestFixture] +public class TelemetryReleaseDateTests +{ + [Test] + public void TryGetReleaseDateReturnsTrueWhenMetadataExists() + { + var assembly = GenerateAssembly("""[assembly: System.Reflection.AssemblyMetadata("GitVersionReleaseDate", "2026-04-26")]"""); + + var result = TelemetryReleaseDate.TryGetReleaseDate(assembly, out var releaseDate); + + result.ShouldBe(true); + releaseDate.ShouldBe(new DateOnly(2026, 04, 26)); + } + + [Test] + public void TryGetReleaseDateReturnsFalseWhenMetadataIsMissing() + { + var assembly = GenerateAssembly(string.Empty); + + var result = TelemetryReleaseDate.TryGetReleaseDate(assembly, out _); + + result.ShouldBe(false); + } + + [Test] + public void TryGetReleaseDateReturnsFalseWhenMetadataHasInvalidFormat() + { + var assembly = GenerateAssembly("""[assembly: System.Reflection.AssemblyMetadata("GitVersionReleaseDate", "2026-04-26T15:00:00Z")]"""); + + var result = TelemetryReleaseDate.TryGetReleaseDate(assembly, out _); + + result.ShouldBe(false); + } + + [Test] + public void IsWithinWindowReturnsTrueBeforeThreeMonths() => + TelemetryReleaseDate.IsWithinWindow(new DateOnly(2026, 04, 26), new DateOnly(2026, 07, 25)).ShouldBe(true); + + [Test] + public void IsWithinWindowReturnsFalseAtThreeMonths() => + TelemetryReleaseDate.IsWithinWindow(new DateOnly(2026, 04, 26), new DateOnly(2026, 07, 26)).ShouldBe(false); + + private static Assembly GenerateAssembly(string attributes) => Assembly.Load(CompileAssembly(attributes).ToArray()); + + private static MemoryStream CompileAssembly(string attributes) + { + var compilation = CSharpCompilation.Create("test-telemetry-release-date") + .WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddReferences(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddReferences(MetadataReference.CreateFromFile(typeof(AssemblyMetadataAttribute).Assembly.Location)) + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(attributes)); + + var memoryStream = new MemoryStream(); + compilation.Emit(memoryStream); + return memoryStream; + } +} diff --git a/src/GitVersion.App.Tests/TelemetryReporterTests.cs b/src/GitVersion.App.Tests/TelemetryReporterTests.cs new file mode 100644 index 0000000000..44f80dece7 --- /dev/null +++ b/src/GitVersion.App.Tests/TelemetryReporterTests.cs @@ -0,0 +1,163 @@ +using GitVersion.Core.Tests.Helpers; + +namespace GitVersion.App.Tests; + +[TestFixture] +public class TelemetryReporterTests +{ + [Test] + public void ReportWritesNoticeOnceAndEmitsTelemetryWhenSinkIsEnabled() + { + var output = new StringBuilder(); + var sink = new TestTelemetrySink(); + var reporter = new TelemetryReporter( + new TestConsoleAdapter(output), new TestTelemetryNoticeState(), sink, new TestTelemetryContextEnricher("github-actions", TelemetryContextValues.GitVersionMsBuild), + new TestTelemetryReleaseDateProvider(true, new(2026, 04, 26)), + new TestTelemetryUtcDateProvider(new(2026, 04, 26))); + var arguments = new Arguments + { + Telemetry = CreateTelemetry(), + TelemetryOptOut = false + }; + + reporter.Report(arguments); + reporter.Report(arguments); + + sink.Payloads.Count.ShouldBe(2); + sink.Payloads[0].ContinuousIntegrationProvider.ShouldBe("github-actions"); + sink.Payloads[0].InvocationSource.ShouldBe(TelemetryContextValues.GitVersionMsBuild); + sink.Payloads[1].ContinuousIntegrationProvider.ShouldBe("github-actions"); + sink.Payloads[1].InvocationSource.ShouldBe(TelemetryContextValues.GitVersionMsBuild); + output.ToString().Split("Telemetry", StringSplitOptions.None).Length.ShouldBe(2); + } + + [Test] + public void ReportSkipsOptedOutInvocations() + { + var sink = new TestTelemetrySink(); + var reporter = new TelemetryReporter( + new TestConsoleAdapter(new StringBuilder()), new TestTelemetryNoticeState(), sink, new TestTelemetryContextEnricher(), + new TestTelemetryReleaseDateProvider(true, new(2026, 04, 26)), + new TestTelemetryUtcDateProvider(new(2026, 04, 26))); + var arguments = new Arguments + { + Telemetry = CreateTelemetry(), + TelemetryOptOut = true + }; + + reporter.Report(arguments); + + sink.Payloads.ShouldBeEmpty(); + } + + [Test] + public void ReportSkipsTelemetryWhenReleaseDateIsMissing() + { + var sink = new TestTelemetrySink(); + var reporter = new TelemetryReporter( + new TestConsoleAdapter(new StringBuilder()), new TestTelemetryNoticeState(), sink, new TestTelemetryContextEnricher(), + new TestTelemetryReleaseDateProvider(false, default), + new TestTelemetryUtcDateProvider(new(2026, 04, 26))); + var arguments = new Arguments + { + Telemetry = CreateTelemetry(), + TelemetryOptOut = false + }; + + reporter.Report(arguments); + + sink.Payloads.ShouldBeEmpty(); + } + + [Test] + public void ReportSkipsTelemetryWhenReleaseWindowHasExpired() + { + var sink = new TestTelemetrySink(); + var reporter = new TelemetryReporter( + new TestConsoleAdapter(new StringBuilder()), new TestTelemetryNoticeState(), sink, new TestTelemetryContextEnricher(), + new TestTelemetryReleaseDateProvider(true, new(2026, 01, 01)), + new TestTelemetryUtcDateProvider(new(2026, 04, 01))); + var arguments = new Arguments + { + Telemetry = CreateTelemetry(), + TelemetryOptOut = false + }; + + reporter.Report(arguments); + + sink.Payloads.ShouldBeEmpty(); + } + + [Test] + public void ReportDefaultsInvocationSourceToDirectWhenNoCallerIsDetected() + { + var sink = new TestTelemetrySink(); + var reporter = new TelemetryReporter( + new TestConsoleAdapter(new StringBuilder()), new TestTelemetryNoticeState(), sink, new TestTelemetryContextEnricher(), + new TestTelemetryReleaseDateProvider(true, new(2026, 04, 26)), + new TestTelemetryUtcDateProvider(new(2026, 04, 26))); + var arguments = new Arguments + { + Telemetry = CreateTelemetry(), + TelemetryOptOut = false + }; + + reporter.Report(arguments); + + sink.Payloads.Single().ContinuousIntegrationProvider.ShouldBe(TelemetryContextValues.Unknown); + sink.Payloads.Single().InvocationSource.ShouldBe(TelemetryContextValues.Direct); + } + + private static CommandLineTelemetry CreateTelemetry() => new( + "1.2.3", + nameof(ArgumentParser), + TelemetryContextValues.Unknown, + TelemetryContextValues.Direct, + "gitversion", + null, + []); + + private sealed class TestTelemetrySink : ITelemetrySink + { + public bool IsEnabled => true; + + public List Payloads { get; } = []; + + public void Write(CommandLineTelemetry telemetry) => Payloads.Add(telemetry); + } + + private sealed class TestTelemetryNoticeState : ITelemetryNoticeState + { + private bool hasSeenNotice; + + public bool HasSeenNotice() => this.hasSeenNotice; + + public void MarkNoticeSeen() => this.hasSeenNotice = true; + } + + private sealed class TestTelemetryReleaseDateProvider(bool hasReleaseDate, DateOnly releaseDate) : ITelemetryReleaseDateProvider + { + public bool TryGetReleaseDate(out DateOnly value) + { + value = releaseDate; + return hasReleaseDate; + } + } + + private sealed class TestTelemetryContextEnricher( + string continuousIntegrationProvider = TelemetryContextValues.Unknown, + string invocationSource = TelemetryContextValues.Direct + ) : ITelemetryContextEnricher + { + public CommandLineTelemetry Enrich(CommandLineTelemetry telemetry) => telemetry with + { + ContinuousIntegrationProvider = continuousIntegrationProvider, + InvocationSource = invocationSource + }; + } + + private sealed class TestTelemetryUtcDateProvider(DateOnly utcToday) : ITelemetryUtcDateProvider + { + public DateOnly UtcToday { get; } = utcToday; + } +} diff --git a/src/GitVersion.App/ArgumentParser.cs b/src/GitVersion.App/ArgumentParser.cs index cca9f8dada..32e0874f41 100644 --- a/src/GitVersion.App/ArgumentParser.cs +++ b/src/GitVersion.App/ArgumentParser.cs @@ -63,8 +63,11 @@ public Arguments ParseArguments(string[] commandLineArguments) } var arguments = new Arguments(); + var telemetry = new TelemetryCollectionBuilder(nameof(ArgumentParser)); AddAuthentication(arguments); - MapParsedValues(arguments, parseResult, options); + MapParsedValues(arguments, parseResult, options, commandLineArguments, telemetry); + arguments.Telemetry = telemetry.Build(); + arguments.TelemetryOptOut = TelemetryPolicy.IsOptedOut(this.environment, parseResult.GetValue(options.TelemetryOptOut)); ValidateConfigurationFile(arguments); return arguments; @@ -107,62 +110,123 @@ private static string ExtractUnrecognizedToken(string message) return start >= 0 && end > start ? message[(start + 1)..end] : message; } - private void MapParsedValues(Arguments arguments, ParseResult parseResult, CommandOptions options) + private void MapParsedValues( + Arguments arguments, + ParseResult parseResult, + CommandOptions options, + string[] commandLineArguments, + TelemetryCollectionBuilder telemetry) { - arguments.LogFilePath = parseResult.GetValue(options.LogFile) ?? arguments.LogFilePath; + if (commandLineArguments.Length > 0 && !commandLineArguments[0].StartsWith('-')) + { + telemetry.AddValue(TelemetryArgumentNames.Path, commandLineArguments[0], TelemetryValueKind.Path); + } + + if (parseResult.GetResult(options.LogFile) is { Implicit: false }) + { + arguments.LogFilePath = parseResult.GetValue(options.LogFile) ?? arguments.LogFilePath; + telemetry.AddValue(TelemetryArgumentNames.LogFile, arguments.LogFilePath, TelemetryValueKind.Path); + } + arguments.Diag = parseResult.GetValue(options.Diagnose); + if (arguments.Diag) + { + telemetry.AddFlag(TelemetryArgumentNames.Diagnose); + } + arguments.ShowConfiguration = parseResult.GetValue(options.ShowConfig); + if (arguments.ShowConfiguration) + { + telemetry.AddFlag(TelemetryArgumentNames.ShowConfig); + } + arguments.NoFetch = parseResult.GetValue(options.NoFetch); + if (arguments.NoFetch) + { + telemetry.AddFlag(TelemetryArgumentNames.NoFetch); + } + arguments.NoCache = parseResult.GetValue(options.NoCache); + if (arguments.NoCache) + { + telemetry.AddFlag(TelemetryArgumentNames.NoCache); + } + arguments.NoNormalize = parseResult.GetValue(options.NoNormalize); + if (arguments.NoNormalize) + { + telemetry.AddFlag(TelemetryArgumentNames.NoNormalize); + } + arguments.AllowShallow = parseResult.GetValue(options.AllowShallow); + if (arguments.AllowShallow) + { + telemetry.AddFlag(TelemetryArgumentNames.AllowShallow); + } + arguments.UpdateWixVersionFile = parseResult.GetValue(options.UpdateWixVersionFile); + if (arguments.UpdateWixVersionFile) + { + telemetry.AddFlag(TelemetryArgumentNames.UpdateWixVersionFile); + } - if (parseResult.GetValue(options.Output) is { } outputs) + if (parseResult.GetResult(options.Output) is { Implicit: false } && parseResult.GetValue(options.Output) is { } outputs) { foreach (var output in outputs) { arguments.Output.Add(output); } + + telemetry.AddValues(TelemetryArgumentNames.Output, outputs.Select(output => output.ToString().ToLowerInvariant())); } - if (parseResult.GetValue(options.OutputFile) is { } outputFile) + if (parseResult.GetResult(options.OutputFile) is { Implicit: false } && parseResult.GetValue(options.OutputFile) is { } outputFile) { arguments.OutputFile = outputFile; + telemetry.AddValue(TelemetryArgumentNames.OutputFile, outputFile, TelemetryValueKind.Path); } - if (parseResult.GetValue(options.ShowVariable) is { } showVariable) + if (parseResult.GetResult(options.ShowVariable) is { Implicit: false } && parseResult.GetValue(options.ShowVariable) is { } showVariable) { ParseShowVariable(arguments, showVariable); + telemetry.AddValue(TelemetryArgumentNames.ShowVariable, showVariable); } - if (parseResult.GetValue(options.Format) is { } format) + if (parseResult.GetResult(options.Format) is { Implicit: false } && parseResult.GetValue(options.Format) is { } format) { ParseFormat(arguments, format); + telemetry.AddValue(TelemetryArgumentNames.Format, format); } - if (parseResult.GetValue(options.Config) is { } config) + if (parseResult.GetResult(options.Config) is { Implicit: false } && parseResult.GetValue(options.Config) is { } config) { arguments.ConfigurationFile = config; + telemetry.AddValue(TelemetryArgumentNames.Config, config, TelemetryValueKind.Path); } - if (parseResult.GetValue(options.OverrideConfig) is { Length: > 0 } overrideConfigs) + if (parseResult.GetResult(options.OverrideConfig) is { Implicit: false } + && parseResult.GetValue(options.OverrideConfig) is { Length: > 0 } overrideConfigs) { ParseOverrideConfig(arguments, overrideConfigs); + telemetry.AddValues(TelemetryArgumentNames.OverrideConfig, overrideConfigs); } - if (parseResult.GetValue(options.TargetPath) is { } targetPath) + if (parseResult.GetResult(options.TargetPath) is { Implicit: false } && parseResult.GetValue(options.TargetPath) is { } targetPath) { arguments.TargetPath = targetPath; if (string.IsNullOrWhiteSpace(targetPath) || !this.fileSystem.Directory.Exists(targetPath)) { this.console.WriteLine($"The working directory '{targetPath}' does not exist."); } + + telemetry.AddValue(TelemetryArgumentNames.TargetPath, targetPath, TelemetryValueKind.Path); } - if (parseResult.GetValue(options.VerbosityOption) is { } verbosity) + if (parseResult.GetResult(options.VerbosityOption) is { Implicit: false } + && parseResult.GetValue(options.VerbosityOption) is { } verbosity) { this.loggingLevelSwitch.MinimumLevel = VerbosityMaps[ParseVerbosity(verbosity)]; + telemetry.AddValue(TelemetryArgumentNames.Verbosity, verbosity.ToLowerInvariant()); } if (parseResult.GetResult(options.UpdateAssemblyInfo) is { Implicit: false }) @@ -184,6 +248,15 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma } } + if (values is { Length: > 0 }) + { + telemetry.AddValues(TelemetryArgumentNames.UpdateAssemblyInfo, values, TelemetryValueKind.PathOrBoolean); + } + else + { + telemetry.AddFlag(TelemetryArgumentNames.UpdateAssemblyInfo); + } + if (arguments.UpdateProjectFiles) { throw new WarningException("Cannot specify both --update-project-files and --update-assembly-info in the same run. Please rerun GitVersion with only one parameter"); @@ -193,7 +266,8 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma if (parseResult.GetResult(options.UpdateProjectFiles) is { Implicit: false }) { arguments.UpdateProjectFiles = true; - if (parseResult.GetValue(options.UpdateProjectFiles) is { } projectFiles) + var projectFiles = parseResult.GetValue(options.UpdateProjectFiles); + if (projectFiles != null) { foreach (var file in projectFiles.Where(f => !f.IsTrue())) { @@ -201,6 +275,15 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma } } + if (projectFiles is { Length: > 0 }) + { + telemetry.AddValues(TelemetryArgumentNames.UpdateProjectFiles, projectFiles, TelemetryValueKind.PathOrBoolean); + } + else + { + telemetry.AddFlag(TelemetryArgumentNames.UpdateProjectFiles); + } + if (arguments.UpdateAssemblyInfo) { throw new WarningException("Cannot specify both --update-assembly-info and --update-project-files in the same run. Please rerun GitVersion with only one parameter"); @@ -215,6 +298,7 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma if (parseResult.GetValue(options.EnsureAssemblyInfo)) { arguments.EnsureAssemblyInfo = true; + telemetry.AddFlag(TelemetryArgumentNames.EnsureAssemblyInfo); if (arguments.UpdateProjectFiles) { @@ -227,34 +311,46 @@ private void MapParsedValues(Arguments arguments, ParseResult parseResult, Comma throw new WarningException("Can't specify multiple assembly info files when using --ensure-assembly-info, either use a single assembly info file or do not specify --ensure-assembly-info and create assembly info files manually"); } - if (parseResult.GetValue(options.Url) is { } url) + if (parseResult.GetResult(options.Url) is { Implicit: false } && parseResult.GetValue(options.Url) is { } url) { arguments.TargetUrl = url; + telemetry.AddValue(TelemetryArgumentNames.Url, url, TelemetryValueKind.Sensitive); } - if (parseResult.GetValue(options.Branch) is { } branch) + if (parseResult.GetResult(options.Branch) is { Implicit: false } && parseResult.GetValue(options.Branch) is { } branch) { arguments.TargetBranch = branch; + telemetry.AddValue(TelemetryArgumentNames.Branch, branch); } - if (parseResult.GetValue(options.Username) is { } username) + if (parseResult.GetResult(options.Username) is { Implicit: false } && parseResult.GetValue(options.Username) is { } username) { arguments.Authentication.Username = username; + telemetry.AddValue(TelemetryArgumentNames.Username, username, TelemetryValueKind.Sensitive); } - if (parseResult.GetValue(options.Password) is { } password) + if (parseResult.GetResult(options.Password) is { Implicit: false } && parseResult.GetValue(options.Password) is { } password) { arguments.Authentication.Password = password; + telemetry.AddValue(TelemetryArgumentNames.Password, password, TelemetryValueKind.Sensitive); } - if (parseResult.GetValue(options.Commit) is { } commit) + if (parseResult.GetResult(options.Commit) is { Implicit: false } && parseResult.GetValue(options.Commit) is { } commit) { arguments.CommitId = commit; + telemetry.AddValue(TelemetryArgumentNames.Commit, commit); } - if (parseResult.GetValue(options.DynamicRepoLocation) is { } dynRepo) + if (parseResult.GetResult(options.DynamicRepoLocation) is { Implicit: false } + && parseResult.GetValue(options.DynamicRepoLocation) is { } dynRepo) { arguments.ClonePath = dynRepo; + telemetry.AddValue(TelemetryArgumentNames.DynamicRepoLocation, dynRepo, TelemetryValueKind.Path); + } + + if (parseResult.GetValue(options.TelemetryOptOut)) + { + telemetry.AddFlag(TelemetryArgumentNames.TelemetryOptOut); } if (arguments.Output.Count == 0) @@ -410,6 +506,10 @@ Allows GitVersion to run on a shallow clone. { Description = "By default dynamic repositories will be cloned to %tmp%. Use this option to override" }; + var telemetryOptOut = new Option("--telemetry-opt-out") + { + Description = "Disables telemetry for this invocation" + }; var rootCommand = new RootCommand("Use convention to derive a SemVer product version from a GitFlow or GitHub based repository.") { @@ -438,7 +538,8 @@ Allows GitVersion to run on a shallow clone. username, password, commit, - dynamicRepoLocation + dynamicRepoLocation, + telemetryOptOut }; // Configure the built-in help system to wrap at 260 characters to avoid too small help messages @@ -456,7 +557,7 @@ Allows GitVersion to run on a shallow clone. VerbosityOption: verbosity, UpdateAssemblyInfo: updateAssemblyInfo, UpdateProjectFiles: updateProjectFiles, EnsureAssemblyInfo: ensureAssemblyInfo, UpdateWixVersionFile: updateWixVersionFile, Url: url, Branch: branch, Username: username, Password: password, - Commit: commit, DynamicRepoLocation: dynamicRepoLocation + Commit: commit, DynamicRepoLocation: dynamicRepoLocation, TelemetryOptOut: telemetryOptOut )); } @@ -597,6 +698,7 @@ private sealed record CommandOptions( Option Username, Option Password, Option Commit, - Option DynamicRepoLocation + Option DynamicRepoLocation, + Option TelemetryOptOut ); } diff --git a/src/GitVersion.App/ArgumentParserExtensions.cs b/src/GitVersion.App/ArgumentParserExtensions.cs index 02ff5e1514..c072a3ecef 100644 --- a/src/GitVersion.App/ArgumentParserExtensions.cs +++ b/src/GitVersion.App/ArgumentParserExtensions.cs @@ -74,7 +74,7 @@ public bool IsSwitch(string switchName) public bool ArgumentRequiresValue(int argumentIndex) { - var booleanArguments = new[] { "updateassemblyinfo", "ensureassemblyinfo", "nofetch", "nonormalize", "nocache", "allowshallow", "diag" }; + var booleanArguments = new[] { "updateassemblyinfo", "ensureassemblyinfo", "nofetch", "nonormalize", "nocache", "allowshallow", "diag", "telemetryoptout" }; var argumentMightRequireValue = !booleanArguments.Contains(singleArgument[1..], StringComparer.OrdinalIgnoreCase); diff --git a/src/GitVersion.App/Arguments.cs b/src/GitVersion.App/Arguments.cs index 3044e54d2e..14a4cd36c2 100644 --- a/src/GitVersion.App/Arguments.cs +++ b/src/GitVersion.App/Arguments.cs @@ -38,6 +38,9 @@ internal class Arguments public bool EnsureAssemblyInfo; public ISet UpdateAssemblyInfoFileName = new HashSet(); + public bool TelemetryOptOut { get; set; } + public CommandLineTelemetry? Telemetry { get; set; } + public GitVersionOptions ToOptions() { var gitVersionOptions = new GitVersionOptions diff --git a/src/GitVersion.App/CliHost.cs b/src/GitVersion.App/CliHost.cs index edfab24e11..17a527bd21 100644 --- a/src/GitVersion.App/CliHost.cs +++ b/src/GitVersion.App/CliHost.cs @@ -34,6 +34,7 @@ private static void RegisterGitVersionModules(IServiceCollection services, strin services.AddModule(new GitVersionOutputModule()); services.AddModule(new GitVersionLibGit2SharpModule()); + services.AddModule(new GitVersionTelemetryModule()); var envValue = SysEnv.GetEnvironmentVariable("GITVERSION_USE_V6_ARGUMENT_PARSER"); var useLegacyParser = string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase); diff --git a/src/GitVersion.App/GitVersionApp.cs b/src/GitVersion.App/GitVersionApp.cs index e5450a136f..8ab3417d6f 100644 --- a/src/GitVersion.App/GitVersionApp.cs +++ b/src/GitVersion.App/GitVersionApp.cs @@ -5,11 +5,15 @@ namespace GitVersion; internal class GitVersionApp( IHostApplicationLifetime applicationLifetime, IGitVersionExecutor gitVersionExecutor, - IOptions options) + IOptions options, + Arguments arguments, + ITelemetryReporter telemetryReporter) { private readonly IHostApplicationLifetime applicationLifetime = applicationLifetime.NotNull(); private readonly IGitVersionExecutor gitVersionExecutor = gitVersionExecutor.NotNull(); private readonly IOptions options = options.NotNull(); + private readonly Arguments arguments = arguments.NotNull(); + private readonly ITelemetryReporter telemetryReporter = telemetryReporter.NotNull(); public Task RunAsync(CancellationToken _) { @@ -22,6 +26,7 @@ public Task RunAsync(CancellationToken _) } else { + this.telemetryReporter.Report(this.arguments); SysEnv.ExitCode = this.gitVersionExecutor.Execute(gitVersionOptions); } } diff --git a/src/GitVersion.App/GitVersionAppModule.cs b/src/GitVersion.App/GitVersionAppModule.cs index 1bf2aa492c..e571411e88 100644 --- a/src/GitVersion.App/GitVersionAppModule.cs +++ b/src/GitVersion.App/GitVersionAppModule.cs @@ -21,9 +21,10 @@ public void RegisterTypes(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService().ParseArguments(args ?? [])); services.AddSingleton(sp => { - var arguments = sp.GetRequiredService().ParseArguments(args ?? []); + var arguments = sp.GetRequiredService(); var gitVersionOptions = arguments.ToOptions(); return Options.Create(gitVersionOptions); }); diff --git a/src/GitVersion.App/LegacyArgumentParser.cs b/src/GitVersion.App/LegacyArgumentParser.cs index 3e205a2dfd..af51a2cdcc 100644 --- a/src/GitVersion.App/LegacyArgumentParser.cs +++ b/src/GitVersion.App/LegacyArgumentParser.cs @@ -49,6 +49,8 @@ public Arguments ParseArguments(string commandLineArguments) public Arguments ParseArguments(string[] commandLineArguments) { + var telemetry = new TelemetryCollectionBuilder(nameof(LegacyArgumentParser)); + if (commandLineArguments.Length == 0) { var args = new Arguments @@ -59,6 +61,8 @@ public Arguments ParseArguments(string[] commandLineArguments) args.Output.Add(OutputType.Json); AddAuthentication(args); + args.Telemetry = telemetry.Build(); + args.TelemetryOptOut = TelemetryPolicy.IsOptedOut(this.environment, args.TelemetryOptOut); return args; } @@ -109,11 +113,164 @@ public Arguments ParseArguments(string[] commandLineArguments) if (!arguments.EnsureAssemblyInfo) arguments.UpdateAssemblyInfoFileName = ResolveFiles(arguments.TargetPath, arguments.UpdateAssemblyInfoFileName).ToHashSet(); + PopulateTelemetry(telemetry, switchesAndValues, commandLineArguments, firstArgumentIsSwitch); + arguments.Telemetry = telemetry.Build(); + arguments.TelemetryOptOut = TelemetryPolicy.IsOptedOut(this.environment, arguments.TelemetryOptOut); ValidateConfigurationFile(arguments); return arguments; } + private static void PopulateTelemetry( + TelemetryCollectionBuilder telemetry, + NameValueCollection switchesAndValues, + string[] commandLineArguments, + bool firstArgumentIsSwitch) + { + if (!firstArgumentIsSwitch && commandLineArguments.Length > 0) + { + telemetry.AddValue(TelemetryArgumentNames.Path, commandLineArguments[0], TelemetryValueKind.Path); + } + + foreach (var key in switchesAndValues.AllKeys) + { + if (key == null) + { + continue; + } + + var values = switchesAndValues.GetValues(key); + var value = values?.FirstOrDefault(); + + if (key.IsSwitch("l")) + { + telemetry.AddValue(TelemetryArgumentNames.LogFile, value, TelemetryValueKind.Path); + } + else if (key.IsSwitch("config")) + { + telemetry.AddValue(TelemetryArgumentNames.Config, value, TelemetryValueKind.Path); + } + else if (key.IsSwitch("overrideconfig")) + { + telemetry.AddValues(TelemetryArgumentNames.OverrideConfig, values); + } + else if (key.IsSwitch("showConfig")) + { + AddBooleanValue(telemetry, TelemetryArgumentNames.ShowConfig, value); + } + else if (key.IsSwitch("diag")) + { + telemetry.AddFlag(TelemetryArgumentNames.Diagnose); + } + else if (key.IsSwitch("updateprojectfiles")) + { + AddPathOrBooleanValues(telemetry, TelemetryArgumentNames.UpdateProjectFiles, values); + } + else if (key.IsSwitch("updateAssemblyInfo")) + { + AddPathOrBooleanValues(telemetry, TelemetryArgumentNames.UpdateAssemblyInfo, values); + } + else if (key.IsSwitch("ensureassemblyinfo")) + { + AddBooleanValue(telemetry, TelemetryArgumentNames.EnsureAssemblyInfo, value); + } + else if (key.IsSwitch("v") || key.IsSwitch("showvariable")) + { + telemetry.AddValue(TelemetryArgumentNames.ShowVariable, value); + } + else if (key.IsSwitch("format")) + { + telemetry.AddValue(TelemetryArgumentNames.Format, value); + } + else if (key.IsSwitch("output")) + { + telemetry.AddValues(TelemetryArgumentNames.Output, values); + } + else if (key.IsSwitch("outputfile")) + { + telemetry.AddValue(TelemetryArgumentNames.OutputFile, value, TelemetryValueKind.Path); + } + else if (key.IsSwitch("nofetch")) + { + telemetry.AddFlag(TelemetryArgumentNames.NoFetch); + } + else if (key.IsSwitch("nonormalize")) + { + telemetry.AddFlag(TelemetryArgumentNames.NoNormalize); + } + else if (key.IsSwitch("nocache")) + { + telemetry.AddFlag(TelemetryArgumentNames.NoCache); + } + else if (key.IsSwitch("allowshallow")) + { + telemetry.AddFlag(TelemetryArgumentNames.AllowShallow); + } + else if (key.IsSwitch("verbosity")) + { + telemetry.AddValue(TelemetryArgumentNames.Verbosity, value?.ToLowerInvariant()); + } + else if (key.IsSwitch("updatewixversionfile")) + { + telemetry.AddFlag(TelemetryArgumentNames.UpdateWixVersionFile); + } + else if (key.IsSwitch("targetpath")) + { + telemetry.AddValue(TelemetryArgumentNames.TargetPath, value, TelemetryValueKind.Path); + } + else if (key.IsSwitch("dynamicRepoLocation")) + { + telemetry.AddValue(TelemetryArgumentNames.DynamicRepoLocation, value, TelemetryValueKind.Path); + } + else if (key.IsSwitch("url")) + { + telemetry.AddValue(TelemetryArgumentNames.Url, value, TelemetryValueKind.Sensitive); + } + else if (key.IsSwitch("u")) + { + telemetry.AddValue(TelemetryArgumentNames.Username, value, TelemetryValueKind.Sensitive); + } + else if (key.IsSwitch("p")) + { + telemetry.AddValue(TelemetryArgumentNames.Password, value, TelemetryValueKind.Sensitive); + } + else if (key.IsSwitch("c")) + { + telemetry.AddValue(TelemetryArgumentNames.Commit, value); + } + else if (key.IsSwitch("b")) + { + telemetry.AddValue(TelemetryArgumentNames.Branch, value); + } + else if (key.IsSwitch("telemetryoptout")) + { + telemetry.AddFlag(TelemetryArgumentNames.TelemetryOptOut); + } + } + } + + private static void AddBooleanValue(TelemetryCollectionBuilder telemetry, string name, string? value) + { + if (value.IsFalse()) + { + telemetry.AddValue(name, "false"); + return; + } + + telemetry.AddFlag(name); + } + + private static void AddPathOrBooleanValues(TelemetryCollectionBuilder telemetry, string name, string[]? values) + { + if (values is { Length: > 0 }) + { + telemetry.AddValues(name, values, TelemetryValueKind.PathOrBoolean); + return; + } + + telemetry.AddFlag(name); + } + private void ValidateConfigurationFile(Arguments arguments) { if (arguments.ConfigurationFile.IsNullOrWhiteSpace()) return; @@ -226,6 +383,12 @@ private bool ParseSwitches(Arguments arguments, string? name, IReadOnlyList(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/GitVersion.App/Telemetry/TelemetryModels.cs b/src/GitVersion.App/Telemetry/TelemetryModels.cs new file mode 100644 index 0000000000..a2e5abb972 --- /dev/null +++ b/src/GitVersion.App/Telemetry/TelemetryModels.cs @@ -0,0 +1,163 @@ +using System.Globalization; + +namespace GitVersion; + +internal enum TelemetryValueKind +{ + Plain, + Path, + PathOrBoolean, + Sensitive +} + +internal static class TelemetryArgumentNames +{ + public const string AllowShallow = "allow-shallow"; + public const string Branch = "branch"; + public const string Commit = "commit"; + public const string Config = "config"; + public const string Diagnose = "diagnose"; + public const string DynamicRepoLocation = "dynamic-repo-location"; + public const string EnsureAssemblyInfo = "ensure-assembly-info"; + public const string Format = "format"; + public const string LogFile = "log-file"; + public const string NoCache = "no-cache"; + public const string NoFetch = "no-fetch"; + public const string NoNormalize = "no-normalize"; + public const string Output = "output"; + public const string OutputFile = "output-file"; + public const string OverrideConfig = "override-config"; + public const string Password = "password"; + public const string Path = "path"; + public const string ShowConfig = "show-config"; + public const string ShowVariable = "show-variable"; + public const string TargetPath = "target-path"; + public const string TelemetryOptOut = "telemetry-opt-out"; + public const string UpdateAssemblyInfo = "update-assembly-info"; + public const string UpdateProjectFiles = "update-project-files"; + public const string UpdateWixVersionFile = "update-wix-version-file"; + public const string Url = "url"; + public const string Username = "username"; + public const string Verbosity = "verbosity"; +} + +internal static class TelemetryReleaseDate +{ + public const string MetadataKey = "GitVersionReleaseDate"; + public const string Format = "yyyy-MM-dd"; + + public static bool TryGetReleaseDate(Assembly assembly, out DateOnly releaseDate) + { + ArgumentNullException.ThrowIfNull(assembly); + + if (assembly + .GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .OfType() + .FirstOrDefault(attribute => attribute.Key == MetadataKey) is not { Value: { } value }) + { + releaseDate = default; + return false; + } + + return DateOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out releaseDate); + } + + public static bool IsWithinWindow(DateOnly releaseDate, DateOnly utcToday) => + utcToday < releaseDate.AddMonths(3); +} + +internal static class TelemetryContextValues +{ + public const string Unknown = "unknown"; + public const string Direct = "direct"; + public const string GitVersionMsBuild = "gitversion-msbuild"; + public const string InternalCallerEnvironmentVariable = "GITVERSION_INTERNAL_CALLER"; +} + +internal sealed record TelemetryArgument(string Name, IReadOnlyList Values); + +internal sealed record CommandLineTelemetry( + string ToolVersion, + string ParserImplementation, + string ContinuousIntegrationProvider, + string InvocationSource, + string Command, + string? Subcommand, + IReadOnlyList Arguments +); + +internal sealed class TelemetryCollectionBuilder(string parserImplementation) +{ + private const string CommandName = "gitversion"; + private const string PathRedactedValue = ""; + private const string SensitiveRedactedValue = ""; + + private readonly List arguments = []; + + public void AddFlag(string name) => AddValues(name, ["true"]); + + public void AddValue(string name, string? value, TelemetryValueKind kind = TelemetryValueKind.Plain) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + AddValues(name, [value], kind); + } + + public void AddValues(string name, IEnumerable? values, TelemetryValueKind kind = TelemetryValueKind.Plain) + { + if (values == null) + { + return; + } + + var sanitizedValues = values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => Sanitize(value, kind)) + .ToArray(); + + if (sanitizedValues.Length == 0) + { + return; + } + + this.arguments.Add(new TelemetryArgument(name, sanitizedValues)); + } + + public CommandLineTelemetry Build() => new( + ToolVersion: TelemetryVersionProvider.GetCurrentVersion(), + ParserImplementation: parserImplementation, + ContinuousIntegrationProvider: TelemetryContextValues.Unknown, + InvocationSource: TelemetryContextValues.Direct, + Command: CommandName, + Subcommand: null, + Arguments: this.arguments + ); + + private static string Sanitize(string value, TelemetryValueKind kind) => kind switch + { + TelemetryValueKind.Path => PathRedactedValue, + TelemetryValueKind.PathOrBoolean when value.IsTrue() || value.IsFalse() => value.ToLowerInvariant(), + TelemetryValueKind.PathOrBoolean => PathRedactedValue, + TelemetryValueKind.Sensitive => SensitiveRedactedValue, + _ => value + }; +} + +internal static class TelemetryVersionProvider +{ + public static string GetCurrentVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + if (assembly + .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false) + .FirstOrDefault() is AssemblyInformationalVersionAttribute attribute) + { + return attribute.InformationalVersion; + } + + return assembly.GetName().Version?.ToString() ?? "unknown"; + } +} diff --git a/src/GitVersion.App/Telemetry/TelemetryPolicy.cs b/src/GitVersion.App/Telemetry/TelemetryPolicy.cs new file mode 100644 index 0000000000..e86376b7c0 --- /dev/null +++ b/src/GitVersion.App/Telemetry/TelemetryPolicy.cs @@ -0,0 +1,12 @@ +namespace GitVersion; + +internal static class TelemetryPolicy +{ + public const string DoNotTrackEnvironmentVariable = "DO_NOT_TRACK"; + public const string GitVersionTelemetryOptOutEnvironmentVariable = "GITVERSION_TELEMETRY_OPTOUT"; + + public static bool IsOptedOut(IEnvironment environment, bool telemetryOptOut) => + telemetryOptOut + || environment.GetEnvironmentVariable(DoNotTrackEnvironmentVariable).IsTrue() + || environment.GetEnvironmentVariable(GitVersionTelemetryOptOutEnvironmentVariable).IsTrue(); +} diff --git a/src/GitVersion.App/Telemetry/TelemetryReporting.cs b/src/GitVersion.App/Telemetry/TelemetryReporting.cs new file mode 100644 index 0000000000..c310d021dd --- /dev/null +++ b/src/GitVersion.App/Telemetry/TelemetryReporting.cs @@ -0,0 +1,178 @@ +using GitVersion.Extensions; + +namespace GitVersion; + +internal interface ITelemetryReleaseDateProvider +{ + bool TryGetReleaseDate(out DateOnly releaseDate); +} + +internal interface ITelemetryUtcDateProvider +{ + DateOnly UtcToday { get; } +} + +internal interface ITelemetrySink +{ + bool IsEnabled { get; } + void Write(CommandLineTelemetry telemetry); +} + +internal interface ITelemetryNoticeState +{ + bool HasSeenNotice(); + void MarkNoticeSeen(); +} + +internal interface ITelemetryReporter +{ + void Report(Arguments arguments); +} + +internal interface ITelemetryContextEnricher +{ + CommandLineTelemetry Enrich(CommandLineTelemetry telemetry); +} + +internal sealed class AssemblyTelemetryReleaseDateProvider : ITelemetryReleaseDateProvider +{ + public bool TryGetReleaseDate(out DateOnly releaseDate) => + TelemetryReleaseDate.TryGetReleaseDate(Assembly.GetExecutingAssembly(), out releaseDate); +} + +internal sealed class TelemetryUtcDateProvider : ITelemetryUtcDateProvider +{ + public DateOnly UtcToday => DateOnly.FromDateTime(DateTime.UtcNow); +} + +internal sealed class NoOpTelemetrySink : ITelemetrySink +{ + public bool IsEnabled => false; + + public void Write(CommandLineTelemetry telemetry) + { + } +} + +internal sealed class TelemetryContextEnricher(Agents.ICurrentBuildAgent currentBuildAgent, IEnvironment environment) : ITelemetryContextEnricher +{ + private readonly Agents.ICurrentBuildAgent currentBuildAgent = currentBuildAgent.NotNull(); + private readonly IEnvironment environment = environment.NotNull(); + + public CommandLineTelemetry Enrich(CommandLineTelemetry telemetry) + { + ArgumentNullException.ThrowIfNull(telemetry); + + return telemetry with + { + ContinuousIntegrationProvider = ResolveContinuousIntegrationProvider(this.currentBuildAgent), + InvocationSource = ResolveInvocationSource(this.environment) + }; + } + + private static string ResolveContinuousIntegrationProvider(Agents.ICurrentBuildAgent currentBuildAgent) => currentBuildAgent.GetType().Name switch + { + "AppVeyor" => "appveyor", + "AzurePipelines" => "azure-pipelines", + "BitBucketPipelines" => "bitbucket-pipelines", + "BuildKite" => "buildkite", + "CodeBuild" => "codebuild", + "ContinuaCi" => "continua-ci", + "Drone" => "drone", + "EnvRun" => "envrun", + "GitHubActions" => "github-actions", + "GitLabCi" => "gitlab-ci", + "Jenkins" => "jenkins", + "MyGet" => "myget", + "SpaceAutomation" => "space-automation", + "TeamCity" => "teamcity", + "TravisCI" => "travis-ci", + _ => TelemetryContextValues.Unknown + }; + + private static string ResolveInvocationSource(IEnvironment environment) + { + var caller = environment.GetEnvironmentVariable(TelemetryContextValues.InternalCallerEnvironmentVariable); + return caller?.Trim() switch + { + { Length: > 0 } value when value.Equals("GitVersion.MsBuild", StringComparison.OrdinalIgnoreCase) => TelemetryContextValues.GitVersionMsBuild, + { Length: > 0 } value when value.Equals(TelemetryContextValues.GitVersionMsBuild, StringComparison.OrdinalIgnoreCase) => TelemetryContextValues.GitVersionMsBuild, + _ => TelemetryContextValues.Direct + }; + } +} + +internal sealed class FileTelemetryNoticeState(System.IO.Abstractions.IFileSystem fileSystem) : ITelemetryNoticeState +{ + private readonly System.IO.Abstractions.IFileSystem fileSystem = fileSystem.NotNull(); + + public bool HasSeenNotice() => this.fileSystem.File.Exists(GetNoticeFilePath()); + + public void MarkNoticeSeen() + { + var noticeFilePath = GetNoticeFilePath(); + var directoryPath = this.fileSystem.Path.GetDirectoryName(noticeFilePath).NotNull(); + this.fileSystem.Directory.CreateDirectory(directoryPath); + + if (!this.fileSystem.File.Exists(noticeFilePath)) + { + this.fileSystem.File.WriteAllText(noticeFilePath, string.Empty); + } + } + + private string GetNoticeFilePath() + { + var localApplicationData = System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData); + return this.fileSystem.Path.Combine(localApplicationData, "GitVersion", "telemetry.notice"); + } +} + +internal sealed class TelemetryReporter( + IConsole console, + ITelemetryNoticeState telemetryNoticeState, + ITelemetrySink telemetrySink, + ITelemetryContextEnricher telemetryContextEnricher, + ITelemetryReleaseDateProvider telemetryReleaseDateProvider, + ITelemetryUtcDateProvider telemetryUtcDateProvider +) : ITelemetryReporter +{ + private const string TelemetryNotice = """ + Telemetry + --------- + This GitVersion distribution can collect CLI usage telemetry to help inform OSS design decisions. + The payload can include the command name, selected arguments, the GitVersion version, the parser implementation, + the detected CI provider, and whether the CLI was invoked by GitVersion.MsBuild. + Path values and sensitive argument values are redacted. + You can opt out by setting DO_NOT_TRACK=1, GITVERSION_TELEMETRY_OPTOUT=1, or passing --telemetry-opt-out. + Read more: https://gitversion.net/docs/usage/cli/telemetry + """; + + private readonly IConsole console = console.NotNull(); + private readonly ITelemetryNoticeState telemetryNoticeState = telemetryNoticeState.NotNull(); + private readonly ITelemetrySink telemetrySink = telemetrySink.NotNull(); + private readonly ITelemetryContextEnricher telemetryContextEnricher = telemetryContextEnricher.NotNull(); + private readonly ITelemetryReleaseDateProvider telemetryReleaseDateProvider = telemetryReleaseDateProvider.NotNull(); + private readonly ITelemetryUtcDateProvider telemetryUtcDateProvider = telemetryUtcDateProvider.NotNull(); + + public void Report(Arguments arguments) + { + if (arguments.TelemetryOptOut || arguments.Telemetry == null || !this.telemetrySink.IsEnabled) + { + return; + } + + if (!this.telemetryReleaseDateProvider.TryGetReleaseDate(out var releaseDate) + || !TelemetryReleaseDate.IsWithinWindow(releaseDate, this.telemetryUtcDateProvider.UtcToday)) + { + return; + } + + if (!this.telemetryNoticeState.HasSeenNotice()) + { + this.console.WriteLine(TelemetryNotice); + this.telemetryNoticeState.MarkNoticeSeen(); + } + + this.telemetrySink.Write(this.telemetryContextEnricher.Enrich(arguments.Telemetry)); + } +} diff --git a/src/GitVersion.App/legacy_help.md b/src/GitVersion.App/legacy_help.md index 6c1025460d..6b98dd003d 100644 --- a/src/GitVersion.App/legacy_help.md +++ b/src/GitVersion.App/legacy_help.md @@ -33,6 +33,8 @@ GitVersion [path] separated key value pairs e.g. /overrideconfig tag-prefix=Foo) Currently supported config overrides: tag-prefix + /telemetryoptout + Disables telemetry for this invocation. /nocache Bypasses the cache, result will not be written to the cache. /nonormalize Disables normalize step on a build server. /allowshallow Allows GitVersion to run on a shallow clone. diff --git a/src/GitVersion.MsBuild/msbuild/tools/GitVersion.MsBuild.targets b/src/GitVersion.MsBuild/msbuild/tools/GitVersion.MsBuild.targets index 43b18b00e9..271d635db8 100644 --- a/src/GitVersion.MsBuild/msbuild/tools/GitVersion.MsBuild.targets +++ b/src/GitVersion.MsBuild/msbuild/tools/GitVersion.MsBuild.targets @@ -29,7 +29,8 @@ - +