Skip to content

Commit

Permalink
Add basic anonymous usage telemetry
Browse files Browse the repository at this point in the history
Modelled after the .NET SDK: https://learn.microsoft.com/en-us/dotnet/core/tools/telemetry.

NOTE: we use a fully non-PII GUID as the "session id", so that no user data is ever used for any telemetry/logging, thus simplifying the GDPR requirements (no need to purge telemetry data which is quite expensive).
  • Loading branch information
kzu committed Jun 25, 2024
1 parent 76b3de0 commit 4bb02a7
Show file tree
Hide file tree
Showing 26 changed files with 417 additions and 69 deletions.
16 changes: 13 additions & 3 deletions docs/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ token from the standard input, which can be piped from a secure store or environ
```
<!-- {% endraw %} -->

### Telemetry

The `dotnet-sponsor` tool does not collect any telemetry by itself. Sponsor backend services may collect
anonymous usage telemetry to improve your experience, however. Such telemetry is associated by default
with an opaque and random identifier of the tool installation that is not linked to any personal information.

Telemetry data helps the backend team understand how its APIs are used by the tool so they can be improved.
To opt out of associating the backend API invocations with your tool installation, set the
`SPONSOR_CLI_TELEMETRY_OPTOUT` environment variable to `1` or `true`.

### Sponsoring Checks

SponsorLink-enabled libraries and tools can use the previously synchronized sponsor manifest to check the
Expand Down Expand Up @@ -245,9 +255,9 @@ To deploy and configure the backend:
1. [Create an Azure Functions app](https://portal.azure.com/#create/Microsoft.FunctionApp)
1. Setup deployment to the Azure Functions app from your forked repository
1. Configure the following application settings:
* `GitHub__Token`: a GitHub token with permissions to read the sponsorable profile, emails, sponsorships and repositories
* `SponsorLink__Account`: the sponsorable GitHub account name, unless it's the same as the GitHub token owner
* `SponsorLink__PrivateKey`: the Base64-encoded private key (the contents of `curl.key.txt` in the example above)
* `GitHub:Token`: a GitHub token with permissions to read the sponsorable profile, emails, sponsorships and repositories
* `SponsorLink:Account`: the sponsorable GitHub account name, unless it's the same as the GitHub token owner
* `SponsorLink:PrivateKey`: the Base64-encoded private key (the contents of `curl.key.txt` in the example above)

Finally, enable the GitHub identity provider under Settings > Authentication, providing the OAuth app's
Client ID and Client Secret. Make sure you set `Allow unauthenticated requests` and have `Token store` enabled.
Expand Down
3 changes: 1 addition & 2 deletions src/Cli/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
#pragma warning disable CS0436 // Type conflicts with imported type
using System.Diagnostics;
using System.Diagnostics;
using Devlooped.Sponsors;
using DotNetConfig;
using Microsoft.Extensions.DependencyInjection;
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"profiles": {
"Cli": {
"commandName": "Project",
"commandLineArgs": "remove devlooped"
"commandLineArgs": "sync devlooped --issuer https://donkey-emerging-civet.ngrok-free.app/ --force"
}
}
}
5 changes: 4 additions & 1 deletion src/Cli/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,7 @@ COMMANDS:
remove Removes all manifests and notifies issuers to remove backend data too
sync Synchronizes sponsorship manifests
view Validates and displays the active sponsor manifests, if any
```
```
Learn more [about the tool](https://github.com/devlooped/SponsorLink/blob/main/docs/github.md#sponsor-manifest-sync)
and related [telemetry](https://github.com/devlooped/SponsorLink/blob/main/docs/github.md#telemetry).
46 changes: 41 additions & 5 deletions src/Commands/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,42 @@ public static class App
public static CommandApp Create(out IServiceProvider services)
{
var collection = new ServiceCollection();
var sldir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink");
Directory.CreateDirectory(sldir);

// Made transient so each command gets a new copy with potentially updated values.
collection.AddTransient(sp =>
var config = Config.Build(sldir);
// Auto-initialize an opaque instance/installation id
if (!config.TryGetString("sponsorlink", "id", out var id))
{
id = Guid.NewGuid().ToString();
config = config.SetString("sponsorlink", "id", id);
}

// Don't propagate traceparent from client (we don't collect telemetry to correlate).
DistributedContextPropagator.Current = DistributedContextPropagator.CreateNoOutputPropagator();
ActivitySource.AddActivityListener(new ActivityListener
{
var sldir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink");
Directory.CreateDirectory(sldir);
return Config.Build(sldir);
// Forces our activities to be created, thereby being available to pull into headers
ShouldListenTo = activity => activity.Name.StartsWith("Devlooped"),
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.PropagationData,
});

// Made transient so each command gets a new copy with potentially updated values.
collection.AddTransient(sp => Config.Build(sldir));
collection.AddSingleton<IGraphQueryClient>(new CliGraphQueryClient());
collection.AddSingleton<IGitHubAppAuthenticator>(sp => new GitHubAppAuthenticator(sp.GetRequiredService<IHttpClientFactory>()));
collection.AddHttpClient().ConfigureHttpClientDefaults(defaults => defaults.ConfigureHttpClient(http =>
{
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ThisAssembly.Info.Product, ThisAssembly.Info.InformationalVersion));
if (Debugger.IsAttached)
http.Timeout = TimeSpan.FromMinutes(10);

var optout = Environment.GetEnvironmentVariable("SPONSOR_CLI_TELEMETRY_OPTOUT");
if (optout == null || (optout != "1" && optout != "true"))
http.DefaultRequestHeaders.TryAddWithoutValidation("x-telemetry-id", id);

if (Activity.Current is { } activity)
http.DefaultRequestHeaders.TryAddWithoutValidation("x-telemetry-operation", activity.OperationName);
}));
collection.AddHttpClient("GitHub", http =>
{
Expand All @@ -38,6 +58,8 @@ public static CommandApp Create(out IServiceProvider services)
AllowAutoRedirect = false,
});

collection.AddTransient<ICommandInterceptor, ActivityCommandInterceptor>();

var registrar = new TypeRegistrar(collection);
var app = new CommandApp(registrar);
registrar.Services.AddSingleton<ICommandApp>(app);
Expand All @@ -57,4 +79,18 @@ public static CommandApp Create(out IServiceProvider services)

return app;
}

class ActivityCommandInterceptor : ICommandInterceptor
{
Activity? activity;

public void Intercept(CommandContext context, CommandSettings settings)
=> activity = ActivityTracer.Source.StartActivity(context.Name, ActivityKind.Client);

public void InterceptResult(CommandContext context, CommandSettings settings, ref int result)
{
activity?.SetTag("ExitCode", result);
activity?.Dispose();
}
}
}
2 changes: 2 additions & 0 deletions src/Commands/Commands.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<PackageReference Include="Spectre.Console.Analyzer" Version="0.49.1" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Strings" Version="1.4.3" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="1.4.3" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Project" Version="1.4.3" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Constants" Version="1.4.3" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/Commands/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public static IRenderable ToDetails(this JwtSecurityToken jwt, string path)
var header = $"|:magnifying_glass_tilted_left:[link={path}]~{Path.DirectorySeparatorChar}.sponsorlink{path[root.Length..]}[/] |";

var content = new List<IRenderable>
{
new JsonText(jwt.Payload.SerializeToJson())
{
new JsonText(jwt.Payload.SerializeToJson())
};

if (jwt.Claims.Where(c => c.Type == "client_id").Select(c => c.Value).FirstOrDefault() is string client_id)
Expand Down
6 changes: 6 additions & 0 deletions src/Commands/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,10 @@ Code for the backend and this app are [link=https://www.devlooped.com/SponsorLin
<data name="Sync_ManifestNotExpired" xml:space="preserve">
<value>:check_mark_button: [lime]{sponsorable}[/]: manifest expires [yellow]{date}[/] [dim](roles: {roles})[/]</value>
</data>
<data name="FirstRun_Telemetry" xml:space="preserve">
<value>Each sponsor issuer backend may collect anonymous usage data telemetry to improve your experience. To send your preference to not get such telemetry collected, you can set a SPONSOR_CLI_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your favorite shell. You can read more about telemetry [link=https://www.devlooped.com/SponsorLink/github.html#telemetry]in the docs[/].</value>
</data>
<data name="FirstRun_TelemetryTitle" xml:space="preserve">
<value>Telemetry</value>
</data>
</root>
2 changes: 1 addition & 1 deletion src/Commands/RemoveCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ await Status().StartAsync(Remove.Removing(sponsorables.First()), async ctx =>

File.Delete(file);
MarkupLine(Remove.Done(sponsorable));

var padding = new Padding(3, 0, 0, 0);
Write(new Padder(new Markup(Remove.DeletedManifest(Path.Combine("~", ".sponsorlink", "github", sponsorable + ".jwt"))), padding));

Expand Down
13 changes: 10 additions & 3 deletions src/Commands/SyncCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,8 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx
{
// We can directly query via the default HTTP client since the .github repository must be public.
branch = await httpFactory.GetQueryClient().QueryAsync(new GraphQuery(
$"/repos/{sponsorable}/.github", ".default_branch") { IsLegacy = true });
$"/repos/{sponsorable}/.github", ".default_branch")
{ IsLegacy = true });

if (branch != null && branch != "main")
// Retry discovery with non-'main' branch
Expand All @@ -265,6 +266,10 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx
switch (status)
{
case SponsorableManifest.Status.OK when manifest != null:
// Mostly for testing purposes, we can override the issuer.
if (settings.Issuer != null)
manifest.Issuer = settings.Issuer;

manifests.Add(manifest);
break;
case SponsorableManifest.Status.NotFound:
Expand Down Expand Up @@ -302,7 +307,8 @@ await Status().StartAsync(Sync.FetchingManifests(sponsorables.Count), async ctx
continue;
}

var (status, jwt) = await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx => await SponsorManifest.FetchAsync(manifest, token));
using var http = httpFactory.CreateClient();
var (status, jwt) = await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx => await SponsorManifest.FetchAsync(manifest, token, http));
if (status == SponsorManifest.Status.NotSponsoring)
{
var links = string.Join(", ", manifest.Audience.Select(x => $"[link]{x}[/]"));
Expand Down Expand Up @@ -343,7 +349,8 @@ await Status().StartAsync(Sync.Synchronizing(manifest.Sponsorable), async ctx =>
if (string.IsNullOrEmpty(token))
return;

var (status, jwt) = await SponsorManifest.FetchAsync(manifest, token);
using var http = httpFactory.CreateClient();
var (status, jwt) = await SponsorManifest.FetchAsync(manifest, token, http);
if (status == SponsorManifest.Status.NotSponsoring)
{
var links = string.Join(", ", manifest.Audience.Select(x => $"[link]{x}[/]"));
Expand Down
8 changes: 8 additions & 0 deletions src/Commands/WelcomeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ public override int Execute(CommandContext context, WelcomeSettings settings)
Padding = new Padding(2, 1, 2, 0),
});

AnsiConsole.Write(new Panel(new Rows(
new Rule(ThisAssembly.Strings.FirstRun.TelemetryTitle).RuleStyle(Color.MediumPurple2).LeftJustified(),
new Markup(ThisAssembly.Strings.FirstRun.Telemetry)))
{
Border = BoxBorder.None,
Padding = new Padding(2, 1, 2, 0),
});

if (!AnsiConsole.Confirm(ThisAssembly.Strings.FirstRun.Acceptance))
{
return -1;
Expand Down
8 changes: 8 additions & 0 deletions src/Core/ActivityTracer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Diagnostics;

namespace Devlooped.Sponsors;

public static class ActivityTracer
{
public static ActivitySource Source { get; } = new("Devlooped.Sponsors", ThisAssembly.Info.InformationalVersion);
}
3 changes: 2 additions & 1 deletion src/Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="SharpYaml" Version="2.1.1" />
<PackageReference Include="Devlooped.JQ" Version="1.7.1.1" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="1.4.3" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<None Update="@(None)" CopyToOutputDirectory="PreserveNewest" />
<InternalsVisibleTo Include="Tests;Web" />
<InternalsVisibleTo Include="Devlooped.Sponsors.Commands;Tests;Web" />
</ItemGroup>

</Project>
53 changes: 30 additions & 23 deletions src/Core/SponsorManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,38 +34,45 @@ public enum Status
/// <param name="manifest">The SponsorLink manifest provided by the sponsorable account.</param>
/// <param name="jwt">The sponsor manifest token, if sponsoring.</param>
/// <returns>The status of the manifest synchronization.</returns>
public static async Task<(Status, string?)> FetchAsync(SponsorableManifest manifest, string accessToken)
public static async Task<(Status, string?)> FetchAsync(SponsorableManifest manifest, string accessToken, HttpClient? http = default)
{
using var http = new HttpClient();

var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(manifest.Issuer), "me"));
request.Headers.Authorization = new("Bearer", accessToken);
request.Headers.Accept.Add(new("application/jwt"));
var response = await http.SendAsync(request);
var disposeHttp = http == null;
try
{
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(manifest.Issuer), "me"));
request.Headers.Authorization = new("Bearer", accessToken);
request.Headers.Accept.Add(new("application/jwt"));
var response = await (http ??= new HttpClient()).SendAsync(request);

if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return (Status.NotSponsoring, default);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return (Status.NotSponsoring, default);

if (!response.IsSuccessStatusCode)
return (Status.SyncFailure, default);
if (!response.IsSuccessStatusCode)
return (Status.SyncFailure, default);

var jwt = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(jwt))
return (Status.SyncFailure, default);
var jwt = await response.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(jwt))
return (Status.SyncFailure, default);

if (new JwtSecurityTokenHandler().CanReadToken(jwt) == false)
return (Status.SyncFailure, default);
if (new JwtSecurityTokenHandler().CanReadToken(jwt) == false)
return (Status.SyncFailure, default);

try
{
// verify no tampering with the manifest
var claims = manifest.Validate(jwt, out var sectoken);
try
{
// verify no tampering with the manifest
var claims = manifest.Validate(jwt, out var sectoken);

return (Status.Success, jwt);
return (Status.Success, jwt);
}
catch (SecurityTokenException)
{
return (Status.SyncFailure, default);
}
}
catch (SecurityTokenException)
finally
{
return (Status.SyncFailure, default);
if (disposeHttp)
http?.Dispose();
}
}
}
25 changes: 20 additions & 5 deletions src/Core/SponsorableManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public static SponsorableManifest Create(Uri issuer, Uri[] audience, string clie
// Try to detect sponsorlink manifest in the sponsorable .github repo
var url = $"https://github.com/{sponsorable}/.github/raw/{branch ?? "main"}/sponsorlink.jwt";
var disposeHttp = http == null;

// Manifest should be public, so no need for any special HTTP client.
try
{
Expand Down Expand Up @@ -120,11 +120,12 @@ public static bool TryRead(string jwt, [NotNullWhen(true)] out SponsorableManife

int hashcode;
string clientId;
string issuer;

public SponsorableManifest(Uri issuer, Uri[] audience, string clientId, SecurityKey publicKey)
{
this.clientId = clientId;
Issuer = issuer.AbsoluteUri;
this.issuer = issuer.AbsoluteUri;
Audience = audience.Select(a => a.AbsoluteUri.TrimEnd('/')).ToArray();
SecurityKey = publicKey;
Sponsorable = audience.Where(x => x.Host == "github.com").Select(x => x.Segments.LastOrDefault()?.TrimEnd('/')).FirstOrDefault() ??
Expand Down Expand Up @@ -254,7 +255,12 @@ public string Sign(IEnumerable<Claim> claims, RsaSecurityKey? key = default, Tim
RequireAudience = true,
// At least one of the audiences must match the manifest audiences
AudienceValidator = (audiences, _, _) => Audience.Intersect(audiences.Select(x => x.TrimEnd('/'))).Any(),
// We don't validate the issuer in debug builds, to allow testing with localhost-run backend.
#if DEBUG
ValidateIssuer = false,
#else
ValidIssuer = Issuer,
#endif
IssuerSigningKey = SecurityKey,
}, out token);

Expand All @@ -269,7 +275,16 @@ public string Sign(IEnumerable<Claim> claims, RsaSecurityKey? key = default, Tim
/// <remarks>
/// See https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
/// </remarks>
public string Issuer { get; }
public string Issuer
{
get => issuer;
internal set
{
issuer = value;
var thumb = JsonWebKeyConverter.ConvertFromSecurityKey(SecurityKey).ComputeJwkThumbprint();
hashcode = new HashCode().Add(Issuer, ClientId, Convert.ToBase64String(thumb)).AddRange(Audience).ToHashCode();
}
}

/// <summary>
/// The audience for the JWT, which includes the sponsorable account and potentially other sponsoring platforms.
Expand All @@ -286,8 +301,8 @@ public string Sign(IEnumerable<Claim> claims, RsaSecurityKey? key = default, Tim
/// <remarks>
/// See https://www.rfc-editor.org/rfc/rfc8693.html#name-client_id-client-identifier
/// </remarks>
public string ClientId
{
public string ClientId
{
get => clientId;
internal set
{
Expand Down
Loading

0 comments on commit 4bb02a7

Please sign in to comment.