From be9ad39d6768fee2c0715b898319141763295700 Mon Sep 17 00:00:00 2001 From: Maxwell Weru Date: Sun, 2 Jun 2024 16:32:55 +0300 Subject: [PATCH] Collect usage telemetry via OpenTelemetry Telemetry collection can be opted out of by adding `-no-telemetry` to any command or by setting it globally using `falu config set no-telemetry true` --- .github/dependabot.yml | 2 + .github/workflows/build.yml | 2 +- .vscode/settings.json | 1 + .../Commands/Config/ConfigCommandHandler.cs | 3 + src/FaluCli/Config/ConfigValues.cs | 3 + .../CommandLineBuilderExtensions.cs | 73 ++++++++++++++++++- .../Extensions/IHostBuilderExtensions.cs | 50 ++++++++++++- .../Extensions/InvocationContextExtensions.cs | 1 + src/FaluCli/FaluCli.csproj | 5 ++ src/FaluCli/Program.cs | 3 + src/FaluCli/Properties/Resources.Designer.cs | 9 +++ src/FaluCli/Properties/Resources.resx | 3 + 12 files changed, 152 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 40486b13..ff815618 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,8 @@ updates: groups: microsoft: patterns: ['Microsoft.*'] + opentelemetry: + patterns: ['OpenTelemetry*'] system: patterns: ['System.*'] xunit: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2b60b1d..193b7dee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -116,7 +116,7 @@ jobs: # https://github.blog/changelog/2023-10-30-accelerate-your-ci-cd-with-arm-based-hosted-runners-in-github-actions/ # TODO: consider using QEMU emulator for ARM? if: ${{ !contains(matrix.arch, 'arm') }} - run: ./falu logout + run: ./falu logout --no-telemetry working-directory: ${{ github.workspace }}/drop/${{ env.DOTNET_RID }} - name: Upload Artifact (drop) diff --git a/.vscode/settings.json b/.vscode/settings.json index e86abe16..42b9b0c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "faluapp", "idempotency", "Mpesa", + "opentelemetry", "Xunit" ] } \ No newline at end of file diff --git a/src/FaluCli/Commands/Config/ConfigCommandHandler.cs b/src/FaluCli/Commands/Config/ConfigCommandHandler.cs index 71399dc0..eda9127b 100644 --- a/src/FaluCli/Commands/Config/ConfigCommandHandler.cs +++ b/src/FaluCli/Commands/Config/ConfigCommandHandler.cs @@ -53,6 +53,9 @@ public async Task InvokeAsync(InvocationContext context) case "skip-update-check": values.SkipUpdateChecks = bool.Parse(value); break; + case "no-telemetry": + values.NoTelemetry = bool.Parse(value); + break; case "retries": values.Retries = int.Parse(value); break; diff --git a/src/FaluCli/Config/ConfigValues.cs b/src/FaluCli/Config/ConfigValues.cs index a254af64..cd100673 100644 --- a/src/FaluCli/Config/ConfigValues.cs +++ b/src/FaluCli/Config/ConfigValues.cs @@ -8,6 +8,9 @@ internal record ConfigValues [JsonPropertyName("skip_update_checks")] public bool SkipUpdateChecks { get; set; } + [JsonPropertyName("no_telemetry")] + public bool NoTelemetry { get; set; } + [JsonPropertyName("retries")] public int Retries { get; set; } = 0; diff --git a/src/FaluCli/Extensions/CommandLineBuilderExtensions.cs b/src/FaluCli/Extensions/CommandLineBuilderExtensions.cs index 034904f1..ca55316c 100644 --- a/src/FaluCli/Extensions/CommandLineBuilderExtensions.cs +++ b/src/FaluCli/Extensions/CommandLineBuilderExtensions.cs @@ -2,6 +2,7 @@ using Falu.Commands.Login; using Falu.Updates; using Spectre.Console; +using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Text; @@ -14,11 +15,15 @@ namespace System.CommandLine.Builder; /// internal static class CommandLineBuilderExtensions { + private static readonly Reflection.AssemblyName AssemblyName = typeof(CommandLineBuilder).Assembly.GetName(); + private static readonly ActivitySource ActivitySource = new(AssemblyName.Name!, AssemblyName.Version!.ToString()); + public static CommandLineBuilder UseFaluDefaults(this CommandLineBuilder builder) { ArgumentNullException.ThrowIfNull(builder, nameof(builder)); - return builder.UseVersionOption() + return builder.AddTracingMiddleware() + .UseVersionOption() .UseHelp() .UseEnvironmentVariableDirective() .UseParseDirective() @@ -30,6 +35,72 @@ public static CommandLineBuilder UseFaluDefaults(this CommandLineBuilder builder .CancelOnProcessTermination(); } + private static CommandLineBuilder AddTracingMiddleware(this CommandLineBuilder builder) + { + // inspired by https://medium.com/@asimmon/instrumenting-system-commandline-based-net-applications-6d910f91b8a8 + + static string GetFullCommandName(ParseResult parseResult) + { + var names = new List(); + var result = parseResult.CommandResult; + + while (result != null && result != parseResult.RootCommandResult) + { + names.Add(result.Command.Name); + result = result.Parent as CommandResult; + } + + names.Reverse(); + + return string.Join(' ', names); + } + + static string Redact(string value) => Constants.ApiKeyFormat.IsMatch(value) ? "***REDACTED***" : value; + + return builder.AddMiddleware(async (context, next) => + { + var activity = ActivitySource.StartActivity("Command", ActivityKind.Consumer); + if (activity is null) + { + await next(context); + return; + } + + // Track command name, command arguments and username + var commandName = GetFullCommandName(context.ParseResult); + var commandArgs = string.Join(' ', context.ParseResult.Tokens.Select(t => Redact(t.Value))); + activity.DisplayName = commandName; + activity.SetTag("command.name", commandName); + activity.SetTag("command.args", commandArgs); + if (context.TryGetWorkspaceId(out var workspaceId)) activity.SetTag("workspace.id", workspaceId); + if (context.TryGetLiveMode(out var live)) activity.SetTag("live_mode", live.ToString()); + + try + { + await next(context); + + activity?.SetStatus(ActivityStatusCode.Ok); + activity?.Stop(); + } + catch (Exception ex) + { + var cancelled = context.GetCancellationToken().IsCancellationRequested; + + if (!cancelled && activity.IsAllDataRequested) + { + activity.AddTag("exception.type", ex.GetType().FullName); + activity.AddTag("exception.message", ex.Message); + activity.AddTag("exception.stacktrace", ex.StackTrace); + } + + activity.SetStatus(cancelled ? ActivityStatusCode.Ok : ActivityStatusCode.Error); + activity.Stop(); + + throw; + } + }, MiddlewareOrder.Default); // default = 0, anything less than that and the activity will be null because the host (which adds open telemetry) is registered at default too + } + private static void ExceptionHandler(Exception exception, InvocationContext context) { context.ExitCode = 1; diff --git a/src/FaluCli/Extensions/IHostBuilderExtensions.cs b/src/FaluCli/Extensions/IHostBuilderExtensions.cs index e24a43b4..d3cd8f76 100644 --- a/src/FaluCli/Extensions/IHostBuilderExtensions.cs +++ b/src/FaluCli/Extensions/IHostBuilderExtensions.cs @@ -1,10 +1,58 @@ -using System.CommandLine.Hosting; +using Azure.Monitor.OpenTelemetry.Exporter; +using Falu.Config; +using Falu.Updates; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using System.CommandLine.Hosting; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.Hosting; internal static class IHostBuilderExtensions { + // this value is hardcoded because Microsoft does not consider the instrumentation key sensitive + private const string AppInsightsConnectionString = "InstrumentationKey=05728099-c2aa-411d-8f1c-e3aa9689daae;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/;LiveEndpoint=https://westeurope.livediagnostics.monitor.azure.com/;ApplicationId=bb8bb015-675d-4658-a286-5d2108ca437a"; + + public static IHostBuilder AddOpenTelemetry(this IHostBuilder builder, ConfigValues configValues) + { + var invocation = builder.GetInvocationContext(); + var disabled = invocation.IsNoTelemetry() || configValues.NoTelemetry; + if (disabled) return builder; + + builder.ConfigureServices((context, services) => + { + var environment = context.HostingEnvironment; + var configuration = context.Configuration; + var builder = services.AddOpenTelemetry(); + + // configure the resource + builder.ConfigureResource(builder => + { + builder.AddAttributes([new("environment", environment.EnvironmentName)]); + + // add detectors + builder.AddDetector(new OpenTelemetry.ResourceDetectors.Host.HostDetector()); + builder.AddDetector(new OpenTelemetry.ResourceDetectors.ProcessRuntime.ProcessRuntimeDetector()); + + // add service name and version (should override any existing values) + builder.AddService("falu-cli", serviceVersion: VersioningHelper.ProductVersion); + }); + + // add tracing + builder.WithTracing(tracing => + { + tracing.AddSource("System.CommandLine"); + tracing.AddHttpClientInstrumentation(); + + // add exporter to Azure Monitor + var aics = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? AppInsightsConnectionString; + tracing.AddAzureMonitorTraceExporter(options => options.ConnectionString = aics); + }); + }); + + return builder; + } + // this exists to work around trimming which does not keep constructors by default in the System.CommandLine.Hosting library // though Microsoft.Extensions.DependencyInjection does public static IHostBuilder UseCommandHandlerTrimmable< diff --git a/src/FaluCli/Extensions/InvocationContextExtensions.cs b/src/FaluCli/Extensions/InvocationContextExtensions.cs index 51e1d2a3..dd1c0c5b 100644 --- a/src/FaluCli/Extensions/InvocationContextExtensions.cs +++ b/src/FaluCli/Extensions/InvocationContextExtensions.cs @@ -8,6 +8,7 @@ namespace System.CommandLine; internal static class InvocationContextExtensions { public static bool IsVerboseEnabled(this InvocationContext context) => context.ParseResult.ValueForOption("--verbose"); + public static bool IsNoTelemetry(this InvocationContext context) => context.ParseResult.ValueForOption("--no-telemetry"); public static string? GetWorkspaceId(this InvocationContext context) => context.ParseResult.ValueForOption("--workspace"); public static bool? GetLiveMode(this InvocationContext context) => context.ParseResult.ValueForOption("--live"); diff --git a/src/FaluCli/FaluCli.csproj b/src/FaluCli/FaluCli.csproj index 95d7668d..d940bd03 100644 --- a/src/FaluCli/FaluCli.csproj +++ b/src/FaluCli/FaluCli.csproj @@ -40,12 +40,17 @@ + + + + + diff --git a/src/FaluCli/Program.cs b/src/FaluCli/Program.cs index 154ac582..61119c14 100644 --- a/src/FaluCli/Program.cs +++ b/src/FaluCli/Program.cs @@ -74,6 +74,7 @@ rootCommand.Description = "Official CLI tool for Falu."; rootCommand.AddGlobalOption(["-v", "--verbose"], "Whether to output verbosely.", false); rootCommand.AddGlobalOption(["--skip-update-checks"], Res.OptionDescriptionSkipUpdateCheck); // nullable so as to allow checking if not specified +rootCommand.AddGlobalOption(["--no-telemetry"], Res.OptionDescriptionNoTelemetry); var configValuesProvider = new ConfigValuesProvider(); var configValues = await configValuesProvider.GetConfigValuesAsync(); @@ -124,6 +125,8 @@ services.AddTransient(); }); + host.AddOpenTelemetry(configValues); + // System.CommandLine library does not create a scope, so we should skip validation of scopes host.UseDefaultServiceProvider(o => o.ValidateScopes = false); diff --git a/src/FaluCli/Properties/Resources.Designer.cs b/src/FaluCli/Properties/Resources.Designer.cs index c10050bf..ca7903e9 100644 --- a/src/FaluCli/Properties/Resources.Designer.cs +++ b/src/FaluCli/Properties/Resources.Designer.cs @@ -272,6 +272,15 @@ internal static string OptionDescriptionSkipUpdateCheck { } } + /// + /// Looks up a localized string similar to Whether to skip telemetry. Using this option overrides any value set in the configuration. This value can also be set globally using 'falu config set no-telemetry true'. + /// + internal static string OptionDescriptionNoTelemetry { + get { + return ResourceManager.GetString("OptionDescriptionNoTelemetry", resourceCulture); + } + } + /// /// Looks up a localized string similar to The identifier of the workspace being accessed. Use this when you are logged into your account and you want to specify which workspace to target. ///Example: wksp_610010be9228355f14ce6e08. diff --git a/src/FaluCli/Properties/Resources.resx b/src/FaluCli/Properties/Resources.resx index c601b28d..fde1f694 100644 --- a/src/FaluCli/Properties/Resources.resx +++ b/src/FaluCli/Properties/Resources.resx @@ -205,6 +205,9 @@ Example: my-awesome-op Whether to skip update checks. Using this option overrides any value set in the configuration. This value can also be set globally using 'falu config set skip-update-check true' + + Whether to disable telemetry collection. Using this option overrides any value set in the configuration. This value can also be set globally using 'falu config set no-telemetry true' + The identifier of the workspace being accessed. Use this when you are logged into your account and you want to specify which workspace to target. Example: wksp_610010be9228355f14ce6e08