From cf8b8462d07ae78ac06376651ee91342fca50085 Mon Sep 17 00:00:00 2001 From: Chukwuleta Tobechi <47084273+TChukwuleta@users.noreply.github.com> Date: Mon, 4 May 2026 18:23:05 +0100 Subject: [PATCH 1/5] plugin stats implementation --- PluginBuilder/Controllers/ApiController.cs | 41 ++- .../Scripts/22.IncludePluginStatesTable.sql | 29 ++ PluginBuilder/DataModels/PluginDownload.cs | 23 ++ PluginBuilder/Program.cs | 1 + PluginBuilder/Services/TelemetryService.cs | 287 ++++++++++++++++++ .../Plugin/PluginTelemetryRequest.cs | 12 + 6 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql create mode 100644 PluginBuilder/DataModels/PluginDownload.cs create mode 100644 PluginBuilder/Services/TelemetryService.cs create mode 100644 PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs diff --git a/PluginBuilder/Controllers/ApiController.cs b/PluginBuilder/Controllers/ApiController.cs index bf742a2c..83a70edd 100644 --- a/PluginBuilder/Controllers/ApiController.cs +++ b/PluginBuilder/Controllers/ApiController.cs @@ -1,5 +1,6 @@ using System.Reflection; using Dapper; +using MailKit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -15,6 +16,7 @@ using PluginBuilder.Services; using PluginBuilder.Util; using PluginBuilder.Util.Extensions; +using PluginBuilder.ViewModels.Plugin; namespace PluginBuilder.Controllers; @@ -28,7 +30,8 @@ public class ApiController( UserManager userManager, UserVerifiedLogic userVerifiedLogic, IHttpClientFactory httpClientFactory, - ServerEnvironment serverEnvironment) + ServerEnvironment serverEnvironment, + TelemetryService telemetryService) : ControllerBase { private sealed class BuildRow @@ -249,6 +252,9 @@ public async Task Download( await using var conn = await connectionFactory.Open(); await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() }); + + _ = telemetryService.RecordPluginDownload(pluginSlug.ToString(), version.ToString(), Request.Headers.UserAgent.ToString(), HttpContext.Connection.RemoteIpAddress?.ToString()); + if (serverEnvironment.EnableLocalArtifactDownloadProxy && Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) && artifactUri.IsLoopback) { return RedirectToAction( @@ -541,6 +547,39 @@ select CreatePublishedVersion(row.plugin_slug, row.ver, row.btcpay_min_ver, row. return Ok(updates); } + + [AllowAnonymous] + [HttpPost("api/v1/telemetry/plugins")] + public async Task ReportInstalledPlugins([FromBody] PluginTelemetryRequest request) + { + if (request?.Plugins is null || request.Plugins.Count == 0) + return Ok(); + + var userAgent = Request.Headers.UserAgent.ToString(); + + if (!TryParseBTCPayVersion(userAgent, out var btcpayVersion)) + return Ok(); + + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + var plugins = request.Plugins.Where(p => !string.IsNullOrWhiteSpace(p.Slug) && !string.IsNullOrWhiteSpace(p.Version)) + .Select(p => new PluginReport(p.Slug!, p.Version!)).ToList(); + + _ = telemetryService.RecordServerSnapshot(remoteIp, btcpayVersion, plugins); + return Ok(); + } + + private static bool TryParseBTCPayVersion(string userAgent, out string version) + { + version = string.Empty; + var match = System.Text.RegularExpressions.Regex.Match( + userAgent, @"^BTCPayServer/(\d+\.\d+\.\d+[\w.\-]*)"); + if (!match.Success) + return false; + version = match.Groups[1].Value; + return true; + } + private IActionResult ValidationErrorResult(ModelStateDictionary modelState) { List errors = (from error in modelState diff --git a/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql b/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql new file mode 100644 index 00000000..356cb25f --- /dev/null +++ b/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS plugin_downloads +( + id BIGSERIAL PRIMARY KEY, + plugin_slug TEXT NOT NULL, + version TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + hashed_ip TEXT NOT NULL, + btcpay_version TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_plugin_downloads_plugin_slug_timestamp ON plugin_downloads (plugin_slug, timestamp); + + +CREATE TABLE IF NOT EXISTS plugin_server_installs +( + hashed_ip TEXT NOT NULL, + plugin_slug TEXT NOT NULL, + version TEXT NOT NULL, + btcpay_version TEXT NOT NULL, + install_count BIGINT NOT NULL DEFAULT 1, + installed_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + uninstalled_at TIMESTAMPTZ NULL, + PRIMARY KEY (hashed_ip, plugin_slug) +); + +CREATE INDEX IF NOT EXISTS ix_plugin_server_installs_plugin_slug ON plugin_server_installs (plugin_slug); + +CREATE INDEX IF NOT EXISTS ix_plugin_server_installs_plugin_slug_uninstalled ON plugin_server_installs (plugin_slug, uninstalled_at); diff --git a/PluginBuilder/DataModels/PluginDownload.cs b/PluginBuilder/DataModels/PluginDownload.cs new file mode 100644 index 00000000..fe7e589d --- /dev/null +++ b/PluginBuilder/DataModels/PluginDownload.cs @@ -0,0 +1,23 @@ +namespace PluginBuilder.DataModels; + +public class PluginDownload +{ + public long Id { get; set; } + public string PluginSlug { get; set; } = null!; + public string Version { get; set; } = null!; + public DateTimeOffset Timestamp { get; set; } + public string HashedIp { get; set; } = null!; + public string BTCPayVersion { get; set; } = null!; +} + +public class PluginServerInstall +{ + public string HashedIp { get; set; } = null!; + public string PluginSlug { get; set; } = null!; + public string Version { get; set; } = null!; + public string BTCPayVersion { get; set; } = null!; + public long InstallCount { get; set; } + public DateTimeOffset InstalledAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? UninstalledAt { get; set; } +} diff --git a/PluginBuilder/Program.cs b/PluginBuilder/Program.cs index 176cf8a6..791c366f 100644 --- a/PluginBuilder/Program.cs +++ b/PluginBuilder/Program.cs @@ -188,6 +188,7 @@ public void AddServices(IConfiguration configuration, IServiceCollection service services.AddHostedService(); services.AddSingleton(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/PluginBuilder/Services/TelemetryService.cs b/PluginBuilder/Services/TelemetryService.cs new file mode 100644 index 00000000..ec39cd40 --- /dev/null +++ b/PluginBuilder/Services/TelemetryService.cs @@ -0,0 +1,287 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Dapper; +using PluginBuilder.DataModels; + +namespace PluginBuilder.Services; + +public class TelemetryService(DBConnectionFactory connectionFactory, ILogger logger) +{ + private static readonly Regex BTCPayUserAgentRegex = new(@"^BTCPayServer/(\d+\.\d+\.\d+[\w.\-]*)", RegexOptions.Compiled); + + public async Task RecordPluginDownload(string pluginSlug, string version, string? userAgent, string? remoteIp) + { + try + { + if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion)) + return; + + if (!TryGetPublicIp(remoteIp, out var ip)) + return; + + var hashedIp = HashIp(ip!); + var now = DateTimeOffset.UtcNow; + + await using var conn = await connectionFactory.Open(); + await conn.ExecuteAsync(""" + INSERT INTO plugin_downloads (plugin_slug, version, timestamp, hashed_ip, btcpay_version) + VALUES (@PluginSlug, @Version, @Timestamp, @HashedIp, @BTCPayVersion) + """, + new { PluginSlug = pluginSlug, Version = version, Timestamp = now, HashedIp = hashedIp, BTCPayVersion = btcpayVersion }); + + var existing = await conn.QueryFirstOrDefaultAsync(""" + SELECT * FROM plugin_server_installs + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = pluginSlug }); + + if (existing is null) + { + await conn.ExecuteAsync(""" + INSERT INTO plugin_server_installs + (hashed_ip, plugin_slug, version, btcpay_version, installed_at, updated_at, uninstalled_at, install_count) + VALUES + (@HashedIp, @PluginSlug, @Version, @BTCPayVersion, @Now, @Now, NULL, 1) + """, + new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now }); + } + else if (existing.UninstalledAt != null) + { + await conn.ExecuteAsync(""" + UPDATE plugin_server_installs + SET version = @Version, + btcpay_version = @BTCPayVersion, + installed_at = @Now, + updated_at = @Now, + uninstalled_at = NULL, + install_count = install_count + 1 + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now }); + } + else + { + await conn.ExecuteAsync(""" + UPDATE plugin_server_installs + SET version = @Version, btcpay_version = @BTCPayVersion, updated_at = @Now + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = pluginSlug, Version = version, BTCPayVersion = btcpayVersion, Now = now }); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to record download telemetry for {PluginSlug} {Version}", pluginSlug, version); + } + } + + public async Task RecordServerSnapshot(string? remoteIp, string btcpayVersion, IEnumerable plugins) + { + try + { + if (!TryGetPublicIp(remoteIp, out var ip)) + return; + + var hashedIp = HashIp(ip!); + var now = DateTimeOffset.UtcNow; + var pluginList = plugins.ToList(); + + await using var conn = await connectionFactory.Open(); + + var existing = (await conn.QueryAsync(""" + SELECT * FROM plugin_server_installs WHERE hashed_ip = @HashedIp + """, new { HashedIp = hashedIp })).ToList(); + + var reportedSlugs = pluginList.Select(p => p.Slug).ToHashSet(StringComparer.OrdinalIgnoreCase); + var existingBySlug = existing.ToDictionary(x => x.PluginSlug, StringComparer.OrdinalIgnoreCase); + + foreach (var plugin in pluginList) + { + if (existingBySlug.TryGetValue(plugin.Slug, out var existingInstall)) + { + if (existingInstall.UninstalledAt != null) + { + await conn.ExecuteAsync(""" + UPDATE plugin_server_installs + SET version = @Version, btcpay_version = @BTCPayVersion, installed_at = @Now, updated_at = @Now, uninstalled_at = NULL, install_count = install_count + 1 + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now }); + } + else + { + await conn.ExecuteAsync(""" + UPDATE plugin_server_installs + SET version = @Version, btcpay_version = @BTCPayVersion, updated_at = @Now + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now }); + } + } + else + { + await conn.ExecuteAsync(""" + INSERT INTO plugin_server_installs + (hashed_ip, plugin_slug, version, btcpay_version, installed_at, updated_at, uninstalled_at, install_count) + VALUES + (@HashedIp, @PluginSlug, @Version, @BTCPayVersion, @Now, @Now, NULL, 1) + """, + new { HashedIp = hashedIp, PluginSlug = plugin.Slug, Version = plugin.Version, BTCPayVersion = btcpayVersion, Now = now }); + } + } + + foreach (var install in existing.Where(x => x.UninstalledAt == null)) + { + if (!reportedSlugs.Contains(install.PluginSlug)) + { + await conn.ExecuteAsync(""" + UPDATE plugin_server_installs SET uninstalled_at = @Now + WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + """, + new { HashedIp = hashedIp, PluginSlug = install.PluginSlug, Now = now }); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to record server snapshot telemetry"); + } + } + + public async Task GetStats(string pluginSlug) + { + await using var conn = await connectionFactory.Open(); + var totalDownloads = await conn.ExecuteScalarAsync(""" + SELECT COUNT(*) FROM plugin_downloads WHERE plugin_slug = @PluginSlug + """, + new { PluginSlug = pluginSlug }); + + var installStats = await conn.QueryFirstOrDefaultAsync<(int TotalInstalls, int ActiveInstalls, int TotalUninstalls)>(""" + SELECT + COALESCE(SUM(install_count), 0) AS TotalInstalls, + COUNT(*) FILTER (WHERE uninstalled_at IS NULL) AS ActiveInstalls, + COUNT(*) FILTER (WHERE uninstalled_at IS NOT NULL) AS TotalUninstalls + FROM plugin_server_installs + WHERE plugin_slug = @PluginSlug + """, + new { PluginSlug = pluginSlug }); + + var versionBreakdown = (await conn.QueryAsync(""" + SELECT version AS Version, COUNT(*) AS Count + FROM plugin_server_installs + WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL + GROUP BY version + ORDER BY Count DESC + """, + new { PluginSlug = pluginSlug })).ToList(); + + var btcpayVersionBreakdown = (await conn.QueryAsync(""" + SELECT btcpay_version AS Version, COUNT(*) AS Count + FROM plugin_server_installs + WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL + GROUP BY btcpay_version + ORDER BY Count DESC + """, + new { PluginSlug = pluginSlug })).ToList(); + + return new PluginStats( + TotalDownloads: totalDownloads, + TotalInstalls: installStats.TotalInstalls, + TotalUpdates: Math.Max(0, totalDownloads - installStats.TotalInstalls), + ActiveInstalls: installStats.ActiveInstalls, + TotalUninstalls: installStats.TotalUninstalls, + VersionBreakdown: versionBreakdown, + BTCPayVersionBreakdown: btcpayVersionBreakdown + ); + } + + private static bool TryParseBTCPayUserAgent(string? userAgent, out string btcpayVersion) + { + btcpayVersion = string.Empty; + if (string.IsNullOrWhiteSpace(userAgent)) + return false; + + var match = BTCPayUserAgentRegex.Match(userAgent); + if (!match.Success) + return false; + + btcpayVersion = match.Groups[1].Value; + return true; + } + + private static bool TryGetPublicIp(string? remoteIp, out string? result) + { + result = null; + if (string.IsNullOrWhiteSpace(remoteIp)) + return false; + + if (!IPAddress.TryParse(remoteIp, out var ip)) + return false; + + if (IsPrivateOrLoopback(ip)) + return false; + + result = remoteIp; + return true; + } + + private static bool IsPrivateOrLoopback(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) + return true; + + if (ip.IsIPv4MappedToIPv6) + ip = ip.MapToIPv4(); + + var bytes = ip.GetAddressBytes(); + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + // 10.0.0.0/8 + if (bytes[0] == 10) + return true; + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) + return true; + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) + return true; + // 169.254.0.0/16 (link-local) + if (bytes[0] == 169 && bytes[1] == 254) + return true; + } + if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + // fc00::/7 (unique local) + if ((bytes[0] & 0xFE) == 0xFC) + return true; + // fe80::/10 (link-local) + if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80) + return true; + } + return false; + } + + private static string HashIp(string ip) + { + var bytes = Encoding.UTF8.GetBytes(ip); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + +} + +public record PluginReport(string Slug, string Version); +public record PluginStats( + int TotalDownloads, + int TotalInstalls, + int TotalUpdates, + int ActiveInstalls, + int TotalUninstalls, + List VersionBreakdown, + List BTCPayVersionBreakdown + ); + +public record VersionStat(string Version, int Count); diff --git a/PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs b/PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs new file mode 100644 index 00000000..689045ca --- /dev/null +++ b/PluginBuilder/ViewModels/Plugin/PluginTelemetryRequest.cs @@ -0,0 +1,12 @@ +namespace PluginBuilder.ViewModels.Plugin; + +public class PluginTelemetryRequest +{ + public List Plugins { get; set; } = new(); +} + +public class PluginTelemetryItem +{ + public string? Slug { get; set; } + public string? Version { get; set; } +} From 040050ac3858f3c0959997859110296bf1dc9465 Mon Sep 17 00:00:00 2001 From: Chukwuleta Tobechi <47084273+TChukwuleta@users.noreply.github.com> Date: Thu, 7 May 2026 16:36:50 +0100 Subject: [PATCH 2/5] bump changes --- .../APIModels/InstalledPluginRequest.cs | 2 + PluginBuilder/Controllers/ApiController.cs | 50 +++++++++---------- PluginBuilder/Services/TelemetryService.cs | 41 +++++++++------ 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/PluginBuilder/APIModels/InstalledPluginRequest.cs b/PluginBuilder/APIModels/InstalledPluginRequest.cs index a3d420e4..a526de86 100644 --- a/PluginBuilder/APIModels/InstalledPluginRequest.cs +++ b/PluginBuilder/APIModels/InstalledPluginRequest.cs @@ -1,3 +1,5 @@ +using Newtonsoft.Json; + namespace PluginBuilder.APIModels; public sealed record InstalledPluginRequest(string Identifier, string Version); diff --git a/PluginBuilder/Controllers/ApiController.cs b/PluginBuilder/Controllers/ApiController.cs index 83a70edd..96a2e2ff 100644 --- a/PluginBuilder/Controllers/ApiController.cs +++ b/PluginBuilder/Controllers/ApiController.cs @@ -1,6 +1,5 @@ using System.Reflection; using Dapper; -using MailKit; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -16,7 +15,6 @@ using PluginBuilder.Services; using PluginBuilder.Util; using PluginBuilder.Util.Extensions; -using PluginBuilder.ViewModels.Plugin; namespace PluginBuilder.Controllers; @@ -203,6 +201,29 @@ ORDER BY lv.ver DESC return Ok(versions); } + [AllowAnonymous] + [HttpPost("telemetry/plugins")] + [EnableRateLimiting(Policies.PublicApiRateLimit)] + public async Task ReportInstalledPlugins([FromBody] List plugins) + { + if (plugins is null || plugins.Count == 0) + return Ok(); + + var userAgent = Request.Headers.UserAgent.ToString(); + if (!TryParseBTCPayVersion(userAgent, out var btcpayVersion)) + return Ok(); + + var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var xForwardedFor = Request.Headers["X-Forwarded-For"].ToString(); + var xOriginalFor = Request.Headers["X-Original-For"].ToString(); + + var pluginReports = plugins.Where(p => !string.IsNullOrWhiteSpace(p.Identifier) && !string.IsNullOrWhiteSpace(p.Version)) + .Select(p => new PluginReport(p.Identifier, p.Version)).ToList(); + + _ = telemetryService.RecordServerSnapshot(remoteIp, btcpayVersion, pluginReports, xOriginalFor, xForwardedFor); + return Ok(); + } + [AllowAnonymous] [HttpGet("plugins/{pluginSlug}/versions/{version}")] [EnableRateLimiting(Policies.PublicApiRateLimit)] @@ -253,7 +274,8 @@ public async Task Download( await using var conn = await connectionFactory.Open(); await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() }); - _ = telemetryService.RecordPluginDownload(pluginSlug.ToString(), version.ToString(), Request.Headers.UserAgent.ToString(), HttpContext.Connection.RemoteIpAddress?.ToString()); + _ = telemetryService.RecordPluginDownload(pluginSlug.ToString(), version.ToString(), Request.Headers.UserAgent.ToString(), + HttpContext.Connection.RemoteIpAddress?.ToString(), Request.Headers["X-Original-For"].ToString(), Request.Headers["X-Forwarded-For"].ToString()); if (serverEnvironment.EnableLocalArtifactDownloadProxy && Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) && artifactUri.IsLoopback) { @@ -547,28 +569,6 @@ select CreatePublishedVersion(row.plugin_slug, row.ver, row.btcpay_min_ver, row. return Ok(updates); } - - [AllowAnonymous] - [HttpPost("api/v1/telemetry/plugins")] - public async Task ReportInstalledPlugins([FromBody] PluginTelemetryRequest request) - { - if (request?.Plugins is null || request.Plugins.Count == 0) - return Ok(); - - var userAgent = Request.Headers.UserAgent.ToString(); - - if (!TryParseBTCPayVersion(userAgent, out var btcpayVersion)) - return Ok(); - - var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString(); - - var plugins = request.Plugins.Where(p => !string.IsNullOrWhiteSpace(p.Slug) && !string.IsNullOrWhiteSpace(p.Version)) - .Select(p => new PluginReport(p.Slug!, p.Version!)).ToList(); - - _ = telemetryService.RecordServerSnapshot(remoteIp, btcpayVersion, plugins); - return Ok(); - } - private static bool TryParseBTCPayVersion(string userAgent, out string version) { version = string.Empty; diff --git a/PluginBuilder/Services/TelemetryService.cs b/PluginBuilder/Services/TelemetryService.cs index ec39cd40..3631a9e9 100644 --- a/PluginBuilder/Services/TelemetryService.cs +++ b/PluginBuilder/Services/TelemetryService.cs @@ -9,16 +9,16 @@ namespace PluginBuilder.Services; public class TelemetryService(DBConnectionFactory connectionFactory, ILogger logger) { - private static readonly Regex BTCPayUserAgentRegex = new(@"^BTCPayServer/(\d+\.\d+\.\d+[\w.\-]*)", RegexOptions.Compiled); + private static readonly Regex BTCPayUserAgentRegex = new(@"^BTCPayServer/(\d+\.\d+\.\d+)", RegexOptions.Compiled); - public async Task RecordPluginDownload(string pluginSlug, string version, string? userAgent, string? remoteIp) + public async Task RecordPluginDownload(string pluginSlug, string version, string? userAgent, string? remoteIp, string? xOriginalFor = null, string? xForwardedFor = null) { try { if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion)) return; - if (!TryGetPublicIp(remoteIp, out var ip)) + if (!TryGetPublicIp(remoteIp, xOriginalFor, xForwardedFor, out var ip)) return; var hashedIp = HashIp(ip!); @@ -77,11 +77,11 @@ UPDATE plugin_server_installs } } - public async Task RecordServerSnapshot(string? remoteIp, string btcpayVersion, IEnumerable plugins) + public async Task RecordServerSnapshot(string? remoteIp, string btcpayVersion, IEnumerable plugins, string? xOriginalFor = null, string? xForwardedFor = null) { try { - if (!TryGetPublicIp(remoteIp, out var ip)) + if (!TryGetPublicIp(remoteIp, xOriginalFor, xForwardedFor, out var ip)) return; var hashedIp = HashIp(ip!); @@ -211,22 +211,35 @@ private static bool TryParseBTCPayUserAgent(string? userAgent, out string btcpay return true; } - private static bool TryGetPublicIp(string? remoteIp, out string? result) + private static bool TryGetPublicIp(string? remoteIp, string? xOriginalFor, string? xForwardedFor, out string? result) { result = null; - if (string.IsNullOrWhiteSpace(remoteIp)) - return false; + var candidates = new[] { xOriginalFor, xForwardedFor, remoteIp }; + foreach (var candidate in candidates) + { + if (string.IsNullOrWhiteSpace(candidate)) + continue; - if (!IPAddress.TryParse(remoteIp, out var ip)) - return false; + var raw = candidate.Split(',')[0].Trim(); + + if (raw.Contains("]:")) + raw = raw.Substring(1, raw.IndexOf(']') - 1); + else if (raw.Contains(':') && !raw.Contains('.')) + raw = raw.Split(':')[0]; - if (IsPrivateOrLoopback(ip)) - return false; + if (!IPAddress.TryParse(raw, out var ip)) + continue; - result = remoteIp; - return true; + if (IsPrivateOrLoopback(ip)) + continue; + + result = raw; + return true; + } + return false; } + private static bool IsPrivateOrLoopback(IPAddress ip) { if (IPAddress.IsLoopback(ip)) From 76b61329326df9af3981be763b0c93fd387adc25 Mon Sep 17 00:00:00 2001 From: Chukwuleta Tobechi <47084273+TChukwuleta@users.noreply.github.com> Date: Wed, 20 May 2026 14:31:18 +0100 Subject: [PATCH 3/5] telemetry improvement --- PluginBuilder/Controllers/ApiController.cs | 24 +++---- .../Scripts/22.IncludePluginStatesTable.sql | 13 ---- PluginBuilder/Services/TelemetryService.cs | 67 +++++++------------ 3 files changed, 34 insertions(+), 70 deletions(-) diff --git a/PluginBuilder/Controllers/ApiController.cs b/PluginBuilder/Controllers/ApiController.cs index 96a2e2ff..713ac71b 100644 --- a/PluginBuilder/Controllers/ApiController.cs +++ b/PluginBuilder/Controllers/ApiController.cs @@ -210,9 +210,6 @@ public async Task ReportInstalledPlugins([FromBody] List ReportInstalledPlugins([FromBody] List !string.IsNullOrWhiteSpace(p.Identifier) && !string.IsNullOrWhiteSpace(p.Version)) .Select(p => new PluginReport(p.Identifier, p.Version)).ToList(); - _ = telemetryService.RecordServerSnapshot(remoteIp, btcpayVersion, pluginReports, xOriginalFor, xForwardedFor); + _ = telemetryService.RecordServerSnapshot(remoteIp, userAgent, pluginReports, xOriginalFor, xForwardedFor); return Ok(); } + [AllowAnonymous] + [HttpGet("plugins/{pluginSlug}/stats")] + public async Task GetPluginStats(string pluginSlug) + { + var stats = await telemetryService.GetStats(pluginSlug); + return Ok(stats); + } + [AllowAnonymous] [HttpGet("plugins/{pluginSlug}/versions/{version}")] [EnableRateLimiting(Policies.PublicApiRateLimit)] @@ -569,17 +574,6 @@ select CreatePublishedVersion(row.plugin_slug, row.ver, row.btcpay_min_ver, row. return Ok(updates); } - private static bool TryParseBTCPayVersion(string userAgent, out string version) - { - version = string.Empty; - var match = System.Text.RegularExpressions.Regex.Match( - userAgent, @"^BTCPayServer/(\d+\.\d+\.\d+[\w.\-]*)"); - if (!match.Success) - return false; - version = match.Groups[1].Value; - return true; - } - private IActionResult ValidationErrorResult(ModelStateDictionary modelState) { List errors = (from error in modelState diff --git a/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql b/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql index 356cb25f..fb62a2f4 100644 --- a/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql +++ b/PluginBuilder/Data/Scripts/22.IncludePluginStatesTable.sql @@ -1,16 +1,3 @@ -CREATE TABLE IF NOT EXISTS plugin_downloads -( - id BIGSERIAL PRIMARY KEY, - plugin_slug TEXT NOT NULL, - version TEXT NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - hashed_ip TEXT NOT NULL, - btcpay_version TEXT NOT NULL -); - -CREATE INDEX IF NOT EXISTS ix_plugin_downloads_plugin_slug_timestamp ON plugin_downloads (plugin_slug, timestamp); - - CREATE TABLE IF NOT EXISTS plugin_server_installs ( hashed_ip TEXT NOT NULL, diff --git a/PluginBuilder/Services/TelemetryService.cs b/PluginBuilder/Services/TelemetryService.cs index 3631a9e9..a45512c5 100644 --- a/PluginBuilder/Services/TelemetryService.cs +++ b/PluginBuilder/Services/TelemetryService.cs @@ -11,7 +11,8 @@ public class TelemetryService(DBConnectionFactory connectionFactory, ILogger(""" - SELECT * FROM plugin_server_installs - WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug + SELECT * FROM plugin_server_installs WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug """, new { HashedIp = hashedIp, PluginSlug = pluginSlug }); @@ -77,10 +71,15 @@ UPDATE plugin_server_installs } } - public async Task RecordServerSnapshot(string? remoteIp, string btcpayVersion, IEnumerable plugins, string? xOriginalFor = null, string? xForwardedFor = null) + + public async Task RecordServerSnapshot(string? remoteIp, string userAgent, IEnumerable plugins, + string? xOriginalFor = null, string? xForwardedFor = null) { try { + if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion)) + return; + if (!TryGetPublicIp(remoteIp, xOriginalFor, xForwardedFor, out var ip)) return; @@ -137,7 +136,7 @@ INSERT INTO plugin_server_installs if (!reportedSlugs.Contains(install.PluginSlug)) { await conn.ExecuteAsync(""" - UPDATE plugin_server_installs SET uninstalled_at = @Now + UPDATE plugin_server_installs SET uninstalled_at = @Now, install_count = GREATEST(0, install_count - 1) WHERE hashed_ip = @HashedIp AND plugin_slug = @PluginSlug """, new { HashedIp = hashedIp, PluginSlug = install.PluginSlug, Now = now }); @@ -153,43 +152,37 @@ await conn.ExecuteAsync(""" public async Task GetStats(string pluginSlug) { await using var conn = await connectionFactory.Open(); - var totalDownloads = await conn.ExecuteScalarAsync(""" - SELECT COUNT(*) FROM plugin_downloads WHERE plugin_slug = @PluginSlug - """, - new { PluginSlug = pluginSlug }); var installStats = await conn.QueryFirstOrDefaultAsync<(int TotalInstalls, int ActiveInstalls, int TotalUninstalls)>(""" SELECT COALESCE(SUM(install_count), 0) AS TotalInstalls, - COUNT(*) FILTER (WHERE uninstalled_at IS NULL) AS ActiveInstalls, - COUNT(*) FILTER (WHERE uninstalled_at IS NOT NULL) AS TotalUninstalls - FROM plugin_server_installs - WHERE plugin_slug = @PluginSlug - """, - new { PluginSlug = pluginSlug }); + COALESCE(COUNT(*) FILTER (WHERE uninstalled_at IS NULL), 0) AS ActiveInstalls, + COALESCE(COUNT(*) FILTER (WHERE uninstalled_at IS NOT NULL), 0) AS TotalUninstalls + FROM plugin_server_installs WHERE plugin_slug = @PluginSlug + """, new { PluginSlug = pluginSlug }); + + var totalInstalls = installStats == default ? 0 : installStats.TotalInstalls; + var activeInstalls = installStats == default ? 0 : installStats.ActiveInstalls; + var totalUninstalls = installStats == default ? 0 : installStats.TotalUninstalls; var versionBreakdown = (await conn.QueryAsync(""" - SELECT version AS Version, COUNT(*) AS Count + SELECT COALESCE(version, 'unknown') AS Version, COALESCE(COUNT(*), 0) AS Count FROM plugin_server_installs - WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL + WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL AND version IS NOT NULL GROUP BY version ORDER BY Count DESC - """, - new { PluginSlug = pluginSlug })).ToList(); + """, new { PluginSlug = pluginSlug })).ToList(); var btcpayVersionBreakdown = (await conn.QueryAsync(""" - SELECT btcpay_version AS Version, COUNT(*) AS Count + SELECT COALESCE(btcpay_version, 'unknown') AS Version, COALESCE(COUNT(*), 0) AS Count FROM plugin_server_installs - WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL + WHERE plugin_slug = @PluginSlug AND uninstalled_at IS NULL AND btcpay_version IS NOT NULL GROUP BY btcpay_version ORDER BY Count DESC - """, - new { PluginSlug = pluginSlug })).ToList(); + """, new { PluginSlug = pluginSlug })).ToList(); return new PluginStats( - TotalDownloads: totalDownloads, TotalInstalls: installStats.TotalInstalls, - TotalUpdates: Math.Max(0, totalDownloads - installStats.TotalInstalls), ActiveInstalls: installStats.ActiveInstalls, TotalUninstalls: installStats.TotalUninstalls, VersionBreakdown: versionBreakdown, @@ -251,25 +244,19 @@ private static bool IsPrivateOrLoopback(IPAddress ip) var bytes = ip.GetAddressBytes(); if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) { - // 10.0.0.0/8 if (bytes[0] == 10) return true; - // 172.16.0.0/12 if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; - // 192.168.0.0/16 if (bytes[0] == 192 && bytes[1] == 168) return true; - // 169.254.0.0/16 (link-local) if (bytes[0] == 169 && bytes[1] == 254) return true; } if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) { - // fc00::/7 (unique local) if ((bytes[0] & 0xFE) == 0xFC) return true; - // fe80::/10 (link-local) if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80) return true; } @@ -282,19 +269,15 @@ private static string HashIp(string ip) var hash = SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } - - } public record PluginReport(string Slug, string Version); public record PluginStats( - int TotalDownloads, int TotalInstalls, - int TotalUpdates, int ActiveInstalls, int TotalUninstalls, List VersionBreakdown, List BTCPayVersionBreakdown ); -public record VersionStat(string Version, int Count); +public record VersionStat(string Version, long Count); From 22672a7047c3f1722e7237f1f2adef3692678eee Mon Sep 17 00:00:00 2001 From: Chukwuleta Tobechi <47084273+TChukwuleta@users.noreply.github.com> Date: Wed, 20 May 2026 15:13:32 +0100 Subject: [PATCH 4/5] fix code rabbit suggestion --- PluginBuilder/Controllers/ApiController.cs | 4 ++++ PluginBuilder/Services/TelemetryService.cs | 25 +++++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/PluginBuilder/Controllers/ApiController.cs b/PluginBuilder/Controllers/ApiController.cs index 713ac71b..25b9a901 100644 --- a/PluginBuilder/Controllers/ApiController.cs +++ b/PluginBuilder/Controllers/ApiController.cs @@ -216,6 +216,9 @@ public async Task ReportInstalledPlugins([FromBody] List !string.IsNullOrWhiteSpace(p.Identifier) && !string.IsNullOrWhiteSpace(p.Version)) .Select(p => new PluginReport(p.Identifier, p.Version)).ToList(); + + if (pluginReports.Count == 0) + return Ok(); _ = telemetryService.RecordServerSnapshot(remoteIp, userAgent, pluginReports, xOriginalFor, xForwardedFor); return Ok(); @@ -223,6 +226,7 @@ public async Task ReportInstalledPlugins([FromBody] List GetPluginStats(string pluginSlug) { var stats = await telemetryService.GetStats(pluginSlug); diff --git a/PluginBuilder/Services/TelemetryService.cs b/PluginBuilder/Services/TelemetryService.cs index a45512c5..7c6bbd00 100644 --- a/PluginBuilder/Services/TelemetryService.cs +++ b/PluginBuilder/Services/TelemetryService.cs @@ -77,6 +77,7 @@ public async Task RecordServerSnapshot(string? remoteIp, string userAgent, IEnum { try { + var pluginList = plugins.GroupBy(p => p.Slug, StringComparer.OrdinalIgnoreCase).Select(g => g.Last()).ToList(); if (!TryParseBTCPayUserAgent(userAgent, out var btcpayVersion)) return; @@ -85,18 +86,32 @@ public async Task RecordServerSnapshot(string? remoteIp, string userAgent, IEnum var hashedIp = HashIp(ip!); var now = DateTimeOffset.UtcNow; - var pluginList = plugins.ToList(); - await using var conn = await connectionFactory.Open(); + var resolvedPluginList = (await conn.QueryAsync(""" + SELECT p.slug AS Slug, i.version AS Version + FROM unnest(@Identifiers::text[], @Versions::text[]) AS i(identifier, version) + JOIN plugins p ON p.identifier = i.identifier + WHERE i.identifier IS NOT NULL AND i.version IS NOT NULL + """, + new + { + Identifiers = pluginList.Select(p => p.Slug).ToArray(), + Versions = pluginList.Select(p => p.Version).ToArray() + })).ToList(); + + if (resolvedPluginList.Count == 0) + return; + + var existing = (await conn.QueryAsync(""" SELECT * FROM plugin_server_installs WHERE hashed_ip = @HashedIp """, new { HashedIp = hashedIp })).ToList(); - var reportedSlugs = pluginList.Select(p => p.Slug).ToHashSet(StringComparer.OrdinalIgnoreCase); + var reportedSlugs = resolvedPluginList.Select(p => p.Slug).ToHashSet(StringComparer.OrdinalIgnoreCase); var existingBySlug = existing.ToDictionary(x => x.PluginSlug, StringComparer.OrdinalIgnoreCase); - foreach (var plugin in pluginList) + foreach (var plugin in resolvedPluginList) { if (existingBySlug.TryGetValue(plugin.Slug, out var existingInstall)) { @@ -217,7 +232,7 @@ private static bool TryGetPublicIp(string? remoteIp, string? xOriginalFor, strin if (raw.Contains("]:")) raw = raw.Substring(1, raw.IndexOf(']') - 1); - else if (raw.Contains(':') && !raw.Contains('.')) + else if (raw.Contains(':') && !raw.Contains('.') && raw.Count(c => c == ':') == 1) raw = raw.Split(':')[0]; if (!IPAddress.TryParse(raw, out var ip)) From 62103c427501bab6a3d7262aaad80fc151385ca2 Mon Sep 17 00:00:00 2001 From: Chukwuleta Tobechi <47084273+TChukwuleta@users.noreply.github.com> Date: Wed, 20 May 2026 15:23:31 +0100 Subject: [PATCH 5/5] final review --- PluginBuilder/Services/TelemetryService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/PluginBuilder/Services/TelemetryService.cs b/PluginBuilder/Services/TelemetryService.cs index 7c6bbd00..26d5a12f 100644 --- a/PluginBuilder/Services/TelemetryService.cs +++ b/PluginBuilder/Services/TelemetryService.cs @@ -197,7 +197,6 @@ ORDER BY Count DESC """, new { PluginSlug = pluginSlug })).ToList(); return new PluginStats( - TotalInstalls: installStats.TotalInstalls, ActiveInstalls: installStats.ActiveInstalls, TotalUninstalls: installStats.TotalUninstalls, VersionBreakdown: versionBreakdown, @@ -288,7 +287,6 @@ private static string HashIp(string ip) public record PluginReport(string Slug, string Version); public record PluginStats( - int TotalInstalls, int ActiveInstalls, int TotalUninstalls, List VersionBreakdown,