diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc44ec3e..968af5b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Updated POST `/appid-//recipe/session/remove` - Adds `revokeAcrossAllTenants` with default `true` - controls revoking of sessions across all tenants or only a particular tenant +- Updated telemetry to send `connectionUriDomain`, `appId` and `mau` information +- Updated feature flag stats to report `usersCount` per tenant ## [6.0.0] - 2023-06-02 diff --git a/ee/src/main/java/io/supertokens/ee/EEFeatureFlag.java b/ee/src/main/java/io/supertokens/ee/EEFeatureFlag.java index 5cd2c83cc..fda37a46b 100644 --- a/ee/src/main/java/io/supertokens/ee/EEFeatureFlag.java +++ b/ee/src/main/java/io/supertokens/ee/EEFeatureFlag.java @@ -13,6 +13,7 @@ import io.supertokens.cronjobs.telemetry.Telemetry; import io.supertokens.ee.cronjobs.EELicenseCheck; import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlag; import io.supertokens.featureflag.exceptions.InvalidLicenseKeyException; import io.supertokens.featureflag.exceptions.NoLicenseKeyFoundException; import io.supertokens.httpRequest.HttpRequest; @@ -28,6 +29,7 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.TenantConfig; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.ThirdPartyConfig; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.session.sqlStorage.SessionSQLStorage; @@ -231,8 +233,10 @@ private JsonObject getMultiTenancyStats() { Storage storage = StorageLayer.getStorage(tenantConfig.tenantIdentifier, main); - boolean hasUsersOrSessions = ((AuthRecipeStorage) storage).getUsersCount(tenantConfig.tenantIdentifier, null) > 0; + long usersCount = ((AuthRecipeStorage) storage).getUsersCount(tenantConfig.tenantIdentifier, null); + boolean hasUsersOrSessions = (usersCount > 0); hasUsersOrSessions = hasUsersOrSessions || ((SessionSQLStorage) storage).getNumberOfSessions(tenantConfig.tenantIdentifier) > 0; + tenantStat.addProperty("usersCount", usersCount); tenantStat.addProperty("hasUsersOrSessions", hasUsersOrSessions); try { @@ -284,6 +288,17 @@ public JsonObject getPaidFeatureStats() throws StorageQueryException, TenantOrAp EE_FEATURES[] features = getEnabledEEFeaturesFromDbOrCache(); + if (!Arrays.asList(features).contains(EE_FEATURES.MULTI_TENANCY)) { // Check for multitenancy on the base app + EE_FEATURES[] baseFeatures = FeatureFlag.getInstance(main, new AppIdentifier(null, null)) + .getEnabledFeatures(); + for (EE_FEATURES feature: baseFeatures) { + if (feature == EE_FEATURES.MULTI_TENANCY) { + features = Arrays.copyOf(features, features.length + 1); + features[features.length - 1] = EE_FEATURES.MULTI_TENANCY; + } + } + } + for (EE_FEATURES feature : features) { if (feature == EE_FEATURES.DASHBOARD_LOGIN) { usageStats.add(EE_FEATURES.DASHBOARD_LOGIN.toString(), getDashboardLoginStats()); diff --git a/src/main/java/io/supertokens/cronjobs/telemetry/Telemetry.java b/src/main/java/io/supertokens/cronjobs/telemetry/Telemetry.java index b02915005..cf2b1a028 100644 --- a/src/main/java/io/supertokens/cronjobs/telemetry/Telemetry.java +++ b/src/main/java/io/supertokens/cronjobs/telemetry/Telemetry.java @@ -24,7 +24,9 @@ import io.supertokens.cronjobs.CronTaskTest; import io.supertokens.httpRequest.HttpRequest; import io.supertokens.httpRequest.HttpRequestMocking; +import io.supertokens.pluginInterface.ActiveUsersStorage; import io.supertokens.pluginInterface.KeyValueInfo; +import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; @@ -88,13 +90,22 @@ protected void doTaskPerApp(AppIdentifier app) throws Exception { json.addProperty("telemetryId", telemetryId.value); json.addProperty("superTokensVersion", coreVersion); + if (StorageLayer.getBaseStorage(main).getType() == STORAGE_TYPE.SQL) { + ActiveUsersStorage activeUsersStorage = (ActiveUsersStorage) StorageLayer.getStorage(app.getAsPublicTenantIdentifier(), main); + json.addProperty("mau", activeUsersStorage.countUsersActiveSince(app, System.currentTimeMillis() - 30 * 24 * 3600 * 1000L)); + } else { + json.addProperty("mau", 0); + } + json.addProperty("appId", app.getAppId()); + json.addProperty("connectionUriDomain", app.getConnectionUriDomain()); + String url = "https://api.supertokens.io/0/st/telemetry"; // we call the API only if we are not testing the core, of if the request can be mocked (in case a test // wants // to use this) if (!Main.isTesting || HttpRequestMocking.getInstance(main).getMockURL(REQUEST_ID, url) != null) { - HttpRequest.sendJsonPOSTRequest(main, REQUEST_ID, url, json, 10000, 10000, 0); + HttpRequest.sendJsonPOSTRequest(main, REQUEST_ID, url, json, 10000, 10000, 4); ProcessState.getInstance(main).addState(ProcessState.PROCESS_STATE.SENT_TELEMETRY, null); } } diff --git a/src/test/java/io/supertokens/test/FeatureFlagTest.java b/src/test/java/io/supertokens/test/FeatureFlagTest.java index 9f2aa3123..3c663c7c7 100644 --- a/src/test/java/io/supertokens/test/FeatureFlagTest.java +++ b/src/test/java/io/supertokens/test/FeatureFlagTest.java @@ -383,15 +383,256 @@ public void testThatMultitenantStatsAreAccurate() throws Exception { if (tenantId.equals("public")) { assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); } else if (tenantId.equals("t0")) { assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(1, tenantStatObj.get("usersCount").getAsLong()); } else if (tenantId.equals("t1")) { assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); } else if (tenantId.equals("t2")) { assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); assertTrue(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testThatMultitenantStatsAreAccurateForAnApp() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + FeatureFlag.getInstance(process.main).setLicenseKeyAndSyncFeatures(OPAQUE_KEY_WITH_MULTITENANCY_FEATURE); + + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier(null, null, null), + new TenantConfig( + new TenantIdentifier(null, "a1", null), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + new JsonObject() + ) + ); + + for (int i=0; i<5; i++) { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(coreConfig, i+1); + + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, "a1", "t" + i); + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier(null, "a1", null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + coreConfig + ) + ); + + TenantIdentifierWithStorage tenantIdentifierWithStorage = tenantIdentifier.withStorage( + StorageLayer.getStorage(tenantIdentifier, process.getProcess())); + if (i % 3 == 0) { + // Create a user + EmailPassword.signUp( + tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + } else if (i % 3 == 1) { + // Create a session + Session.createNewSession(tenantIdentifierWithStorage, process.getProcess(), "userid", new JsonObject(), new JsonObject()); + } else { + // Create an enterprise provider + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier(null, "a1", null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, new ThirdPartyConfig.Provider[]{ + new ThirdPartyConfig.Provider("okta", "Okta", null, null, null, null, null, null, null, null, null, null, null, null) + }), + new PasswordlessConfig(true), + coreConfig + ) + ); + } + } + + JsonObject response = HttpRequestForTesting.sendGETRequest(process.getProcess(), "", + "http://localhost:3567/appid-a1/ee/featureflag", + null, 1000, 1000, null, WebserverAPI.getLatestCDIVersion().get(), ""); + Assert.assertEquals("OK", response.get("status").getAsString()); + + JsonArray multitenancyStats = response.get("usageStats").getAsJsonObject().get("multi_tenancy").getAsJsonObject().get("tenants").getAsJsonArray(); + assertEquals(6, multitenancyStats.size()); + + Set userPoolIds = new HashSet<>(); + for (JsonElement tenantStat : multitenancyStats) { + JsonObject tenantStatObj = tenantStat.getAsJsonObject(); + String tenantId = tenantStatObj.get("tenantId").getAsString(); + + if (!StorageLayer.isInMemDb(process.getProcess())) { + // Ensure each userPoolId is unique + String userPoolId = tenantStatObj.get("userPoolId").getAsString(); + assertFalse(userPoolIds.contains(userPoolId)); + userPoolIds.add(userPoolId); + } + + if (tenantId.equals("public")) { + assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t0")) { + assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(1, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t1")) { + assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t2")) { + assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertTrue(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testThatMultitenantStatsAreAccurateForACud() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + if (StorageLayer.isInMemDb(process.getProcess())) { + return; + } + + FeatureFlag.getInstance(process.main).setLicenseKeyAndSyncFeatures(OPAQUE_KEY_WITH_MULTITENANCY_FEATURE); + + { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(coreConfig, 1); + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier(null, null, null), + new TenantConfig( + new TenantIdentifier("127.0.0.1", null, null), + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + coreConfig + ) + ); + } + + for (int i=0; i<5; i++) { + JsonObject coreConfig = new JsonObject(); + StorageLayer.getStorage(new TenantIdentifier(null, null, null), process.getProcess()) + .modifyConfigToAddANewUserPoolForTesting(coreConfig, i+2); + + TenantIdentifier tenantIdentifier = new TenantIdentifier("127.0.0.1", null, "t" + i); + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier("127.0.0.1", null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + coreConfig + ) + ); + + TenantIdentifierWithStorage tenantIdentifierWithStorage = tenantIdentifier.withStorage( + StorageLayer.getStorage(tenantIdentifier, process.getProcess())); + if (i % 3 == 0) { + // Create a user + EmailPassword.signUp( + tenantIdentifierWithStorage, process.getProcess(), "user@example.com", "password"); + } else if (i % 3 == 1) { + // Create a session + Session.createNewSession(tenantIdentifierWithStorage, process.getProcess(), "userid", new JsonObject(), new JsonObject()); + } else { + // Create an enterprise provider + Multitenancy.addNewOrUpdateAppOrTenant( + process.getProcess(), + new TenantIdentifier("127.0.0.1", null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, new ThirdPartyConfig.Provider[]{ + new ThirdPartyConfig.Provider("okta", "Okta", null, null, null, null, null, null, null, null, null, null, null, null) + }), + new PasswordlessConfig(true), + coreConfig + ) + ); + } + } + + JsonObject response = HttpRequestForTesting.sendGETRequest(process.getProcess(), "", + "http://127.0.0.1:3567/ee/featureflag", + null, 1000, 1000, null, WebserverAPI.getLatestCDIVersion().get(), ""); + Assert.assertEquals("OK", response.get("status").getAsString()); + + JsonArray multitenancyStats = response.get("usageStats").getAsJsonObject().get("multi_tenancy").getAsJsonObject().get("tenants").getAsJsonArray(); + assertEquals(6, multitenancyStats.size()); + + Set userPoolIds = new HashSet<>(); + for (JsonElement tenantStat : multitenancyStats) { + JsonObject tenantStatObj = tenantStat.getAsJsonObject(); + String tenantId = tenantStatObj.get("tenantId").getAsString(); + + if (!StorageLayer.isInMemDb(process.getProcess())) { + // Ensure each userPoolId is unique + String userPoolId = tenantStatObj.get("userPoolId").getAsString(); + assertFalse(userPoolIds.contains(userPoolId)); + userPoolIds.add(userPoolId); + } + + if (tenantId.equals("public")) { + assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t0")) { + assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(1, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t1")) { + assertTrue(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertFalse(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); + } else if (tenantId.equals("t2")) { + assertFalse(tenantStatObj.get("hasUsersOrSessions").getAsBoolean()); + assertTrue(tenantStatObj.get("hasEnterpriseLogin").getAsBoolean()); + assertEquals(0, tenantStatObj.get("usersCount").getAsLong()); } } diff --git a/src/test/java/io/supertokens/test/TelemetryTest.java b/src/test/java/io/supertokens/test/TelemetryTest.java index 30d81edb7..95e312ac7 100644 --- a/src/test/java/io/supertokens/test/TelemetryTest.java +++ b/src/test/java/io/supertokens/test/TelemetryTest.java @@ -153,6 +153,9 @@ protected URLConnection openConnection(URL u) { assertTrue(telemetryData.has("telemetryId")); assertEquals(telemetryData.get("superTokensVersion").getAsString(), Version.getVersion(process.getProcess()).getCoreVersion()); + assertEquals(telemetryData.get("appId").getAsString(), "public"); + assertEquals(telemetryData.get("connectionUriDomain").getAsString(), ""); + assertTrue(telemetryData.has("mau")); process.kill(); assertNotNull(process.checkOrWaitForEvent(PROCESS_STATE.STOPPED));