From 2c6473a6abf361261ab700b43c8a1cfcfa05359b Mon Sep 17 00:00:00 2001 From: Marc Durdin Date: Mon, 12 Jan 2026 17:31:07 +1100 Subject: [PATCH] feat: collect raw application download statistics This adds download statistics collection for Keyman apps hosted on downloads.keyman.com - specifically Keyman for Windows, Keyman for Mac, and Keyman Developer. Other platforms are distributed through stores or other mechanisms, so will not generally be visible here. For now we collect app, version, and tier data, by day. This can be expanded as needed in the future. Test-bot: skip --- .htaccess | 4 ++ .../1.0/app-downloads-increment.json | 23 ++++++ .../app-downloads-increment.inc.php | 28 ++++++++ .../app-downloads-increment.php | 72 +++++++++++++++++++ tools/db/build/search.sql | 20 +++++- tools/db/build/sp_app_downloads_increment.sql | 39 ++++++++++ tools/db/build/sp_increment_download.sql | 1 + 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 schemas/app-downloads-increment/1.0/app-downloads-increment.json create mode 100644 script/app-downloads-increment/app-downloads-increment.inc.php create mode 100644 script/app-downloads-increment/app-downloads-increment.php create mode 100644 tools/db/build/sp_app_downloads_increment.sql diff --git a/.htaccess b/.htaccess index 6e6005b..2464153 100644 --- a/.htaccess +++ b/.htaccess @@ -32,6 +32,10 @@ RewriteRule "^keyboard/(.*)$" "/script/keyboard/keyboard.php?id=$1" [END] RewriteRule "^increment-download/(.*)$" "/script/increment-download/increment-download.php?id=$1" [END] +#### Rewrites for /script folder: /app-downloads-increment + +RewriteRule "^app-downloads-increment/([^/]+)/([^/]+)/(.*)$" "/script/app-downloads-increment/app-downloads-increment.php?product=$1&version=$2&tier=$3" [END] + #### Rewrites for /script folder: /model RewriteRule "^model(/)?$" "/script/model-search/model-search.php" [END] diff --git a/schemas/app-downloads-increment/1.0/app-downloads-increment.json b/schemas/app-downloads-increment/1.0/app-downloads-increment.json new file mode 100644 index 0000000..0ae5fe2 --- /dev/null +++ b/schemas/app-downloads-increment/1.0/app-downloads-increment.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$ref": "#/definitions/app-downloads-increment", + + "definitions": { + "app-downloads-increment": { + "type": "object", + "required": [ + "product", + "version", + "tier", + "count" + ], + "additionalProperties": true, + "properties": { + "product": { "type": "string" }, + "version": { "type": "string" }, + "tier": { "type": "string" }, + "count": { "type": "integer" } + } + } + } +} \ No newline at end of file diff --git a/script/app-downloads-increment/app-downloads-increment.inc.php b/script/app-downloads-increment/app-downloads-increment.inc.php new file mode 100644 index 0000000..887fea6 --- /dev/null +++ b/script/app-downloads-increment/app-downloads-increment.inc.php @@ -0,0 +1,28 @@ +prepare('EXEC sp_app_downloads_increment :product, :version, :tier'); + $stmt->bindParam(":product", $product); + $stmt->bindParam(":version", $version); + $stmt->bindParam(":tier", $tier); + $stmt->execute(); + $data = $stmt->fetchAll(); + if(count($data) == 0) { + return NULL; + } + + $obj = [ + 'product' => $data[0]['product'], + 'version' => $data[0]['version'], + 'tier' => $data[0]['tier'], + 'count' => intval($data[0]['count']) + ]; + + return $obj; + } + } diff --git a/script/app-downloads-increment/app-downloads-increment.php b/script/app-downloads-increment/app-downloads-increment.php new file mode 100644 index 0000000..3e5ff27 --- /dev/null +++ b/script/app-downloads-increment/app-downloads-increment.php @@ -0,0 +1,72 @@ +api_keyman_com .'/schemas/app-downloads-increment/1.0/app-downloads-increment.json#>; rel="describedby"'); + + $AllowGet = isset($_REQUEST['debug']); + + if(!$AllowGet && $_SERVER['REQUEST_METHOD'] != 'POST') { + fail('POST required'); + } + + if(!isset($_REQUEST['key'])) { + fail('key parameter must be set'); + } + + // Note: we don't currently unit-test this one + if(KeymanHosts::Instance()->Tier() === KeymanHosts::TIER_DEVELOPMENT) + $key = 'local'; + else + $key = $env['API_KEYMAN_COM_INCREMENT_DOWNLOAD_KEY']; + + if($_REQUEST['key'] !== $key) { + fail('Invalid key'); + } + + if(!isset($_REQUEST['product']) || + !isset($_REQUEST['version']) || + !isset($_REQUEST['tier']) + ) { + // We don't constrain what the product / version / tier may be here, because + // we may add other products in the future + fail('product, version, tier parameters must be set'); + } + + $product = $_REQUEST['product']; + $version = $_REQUEST['version']; + $tier = $_REQUEST['tier']; + + /** + * POST https://api.keyman.com/app-downloads-increment/product/version/tier + * + * Increments the download counter for a single product identified by + * `product`, `version`, and `tier`. Returns the new total count for the + * product/version/tier for the day + * + * https://api.keyman.com/schemas/app-downloads-increment.json is JSON schema + * + * @param product the name of the product to increment ( "android", "ios", + * "linux", "macos", "web", "windows", "developer"...) + * @param version the version number ("1.2.3") + * @param tier the tier of the product ("alpha", "beta", "stable") + * @param key internal key to allow endpoint to run + */ + + $json = \Keyman\Site\com\keyman\api\AppDownloads::increment($mssql, $product, $version, $tier); + if($json === NULL) { + fail("Failed to increment stat, invalid parameters [$product, $version, $tier]?", 401); + } + + echo json_encode($json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); \ No newline at end of file diff --git a/tools/db/build/search.sql b/tools/db/build/search.sql index 547e929..d3aaf68 100644 --- a/tools/db/build/search.sql +++ b/tools/db/build/search.sql @@ -306,7 +306,10 @@ CREATE TABLE t_dbdatasources ( DROP TABLE IF EXISTS t_keyboard_downloads; GO ---add a new schema for kstats here so we can use it in search.sql +-- tables in the kstats schema are persistent on the production +-- infrastructure, unlike the other tables in the database, which +-- are recreated on each deployment. + IF SCHEMA_ID('kstats') IS NULL BEGIN EXEC sp_executesql N'CREATE SCHEMA kstats' @@ -325,3 +328,18 @@ BEGIN keyboard_id, statdate ) INCLUDE (count) END + +IF OBJECT_ID('kstats.t_app_downloads', 'U') IS NULL +BEGIN + CREATE TABLE kstats.t_app_downloads ( + product NVARCHAR(64) NOT NULL, -- "android", "ios", "linux", "macos", "web", "windows", "developer"... + version NVARCHAR(64) NOT NULL, -- "123.456.789" + tier NVARCHAR(16) NOT NULL, -- "alpha", "beta", "stable" + statdate DATE, + count INT NOT NULL + ) + + CREATE INDEX ix_app_downloads ON kstats.t_app_downloads ( + product, version, tier, statdate + ) INCLUDE (count) +END diff --git a/tools/db/build/sp_app_downloads_increment.sql b/tools/db/build/sp_app_downloads_increment.sql new file mode 100644 index 0000000..8b7ea71 --- /dev/null +++ b/tools/db/build/sp_app_downloads_increment.sql @@ -0,0 +1,39 @@ +/* + sp_app_downloads_increment +*/ + +DROP PROCEDURE IF EXISTS sp_app_downloads_increment; +GO + +CREATE PROCEDURE sp_app_downloads_increment ( + @prmProduct NVARCHAR(64), + @prmVersion NVARCHAR(64), + @prmTier NVARCHAR(16) +) AS +BEGIN + SET NOCOUNT ON; + + BEGIN TRANSACTION; + + DECLARE @date DATE, @count INT; + + SET @date = CONVERT(date, GETDATE()); + + UPDATE kstats.t_app_downloads + WITH (UPDLOCK, SERIALIZABLE) -- ensure that this statement is atomic with following INSERT + SET count = count + 1, @count = count + 1 + WHERE product = @prmProduct AND version = @prmVersion AND tier = @prmTier AND statdate = @date; + + IF @@ROWCOUNT = 0 + BEGIN + INSERT kstats.t_app_downloads (product, version, tier, statdate, count) + SELECT @prmProduct, @prmVersion, @prmTier, @date, 1 + SET @count = 1 + END + + SET NOCOUNT OFF + + SELECT @prmProduct product, @prmVersion version, @prmTier tier, @count count + + COMMIT TRANSACTION; +END diff --git a/tools/db/build/sp_increment_download.sql b/tools/db/build/sp_increment_download.sql index c628bcd..2f6f6c8 100644 --- a/tools/db/build/sp_increment_download.sql +++ b/tools/db/build/sp_increment_download.sql @@ -1,5 +1,6 @@ /* sp_increment_download + TODO: rename to sp_keyboard_downloads_increment */ DROP PROCEDURE IF EXISTS sp_increment_download;