diff --git a/components/dashboard/src/experiments/client.ts b/components/dashboard/src/experiments/client.ts index 318e0a6cd0676b..12a8fabba587c7 100644 --- a/components/dashboard/src/experiments/client.ts +++ b/components/dashboard/src/experiments/client.ts @@ -4,34 +4,11 @@ * See License-AGPL.txt in the project root for license information. */ -import { Team } from "@gitpod/gitpod-protocol"; -import { newNonProductionConfigCatClient, newProductionConfigCatClient } from "./configcat"; -import { newAlwaysReturningDefaultValueClient } from "./always-default"; - -// Attributes define attributes which can be used to segment audiences. -// Set the attributes which you want to use to group audiences into. -export interface Attributes { - userId?: string; - email?: string; - - // Currently selected Gitpod Project ID - projectId?: string; - - // Currently selected Gitpod Team ID - teamId?: string; - // Currently selected Gitpod Team Name - teamName?: string; - - // All the Gitpod Teams that the user is a member (or owner) of - teams?: Array; -} - -export interface Client { - getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise; - - // dispose will dispose of the client, no longer retrieving flags - dispose(): void; -} +import { newAlwaysReturningDefaultValueClient } from "@gitpod/gitpod-protocol/lib/experiments/always-default"; +import * as configcat from "configcat-js"; +import { ConfigCatClient } from "@gitpod/gitpod-protocol/lib/experiments/configcat"; +import { Client } from "@gitpod/gitpod-protocol/lib/experiments/types"; +import { LogLevel } from "configcat-common"; let client: Client | undefined; @@ -54,8 +31,27 @@ export function getExperimentsClient(): Client { return client; } -export const PROJECT_ID_ATTRIBUTE = "project_id"; -export const TEAM_ID_ATTRIBUTE = "team_id"; -export const TEAM_IDS_ATTRIBUTE = "team_ids"; -export const TEAM_NAME_ATTRIBUTE = "team_name"; -export const TEAM_NAMES_ATTRIBUTE = "team_names"; +// newProductionConfigCatClient constructs a new ConfigCat client with production configuration. +function newProductionConfigCatClient(): Client { + // clientKey is an identifier of our ConfigCat application. It is not a secret. + const clientKey = "WBLaCPtkjkqKHlHedziE9g/TwAe6YyftEGPnGxVRXd0Ig"; + const client = configcat.createClient(clientKey, { + logger: configcat.createConsoleLogger(LogLevel.Error), + maxInitWaitTimeSeconds: 0, + }); + + return new ConfigCatClient(client); +} + +// newNonProductionConfigCatClient constructs a new ConfigCat client with non-production configuration. +function newNonProductionConfigCatClient(): Client { + // clientKey is an identifier of our ConfigCat application. It is not a secret. + const clientKey = "WBLaCPtkjkqKHlHedziE9g/LEAOCNkbuUKiqUZAcVg7dw"; + const client = configcat.createClient(clientKey, { + pollIntervalSeconds: 60 * 3, // 3 minutes + logger: configcat.createConsoleLogger(LogLevel.Info), + maxInitWaitTimeSeconds: 0, + }); + + return new ConfigCatClient(client); +} diff --git a/components/dashboard/src/experiments/configcat.ts b/components/dashboard/src/experiments/configcat.ts deleted file mode 100644 index 16430944037fb0..00000000000000 --- a/components/dashboard/src/experiments/configcat.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2022 Gitpod GmbH. All rights reserved. - * Licensed under the GNU Affero General Public License (AGPL). - * See License-AGPL.txt in the project root for license information. - */ - -import * as configcat from "configcat-js"; -import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient"; -import { User } from "configcat-common/lib/RolloutEvaluator"; -import { - Attributes, - Client, - PROJECT_ID_ATTRIBUTE, - TEAM_IDS_ATTRIBUTE, - TEAM_ID_ATTRIBUTE, - TEAM_NAMES_ATTRIBUTE, - TEAM_NAME_ATTRIBUTE, -} from "./client"; - -// newProductionConfigCatClient constructs a new ConfigCat client with production configuration. -// DO NOT USE DIRECTLY! Use getExperimentsClient() instead. -export function newProductionConfigCatClient(): Client { - // clientKey is an identifier of our ConfigCat application. It is not a secret. - const clientKey = "WBLaCPtkjkqKHlHedziE9g/TwAe6YyftEGPnGxVRXd0Ig"; - const client = configcat.createClient(clientKey, { - maxInitWaitTimeSeconds: 0, - logger: configcat.createConsoleLogger(2), - }); - - return new ConfigCatClient(client); -} - -// newNonProductionConfigCatClient constructs a new ConfigCat client with non-production configuration. -// DO NOT USE DIRECTLY! Use getExperimentsClient() instead. -export function newNonProductionConfigCatClient(): Client { - // clientKey is an identifier of our ConfigCat application. It is not a secret. - const clientKey = "WBLaCPtkjkqKHlHedziE9g/LEAOCNkbuUKiqUZAcVg7dw"; - const client = configcat.createClient(clientKey, { - maxInitWaitTimeSeconds: 0, - pollIntervalSeconds: 60 * 3, // 3 minutes - logger: configcat.createConsoleLogger(3), - }); - - return new ConfigCatClient(client); -} - -class ConfigCatClient implements Client { - private client: IConfigCatClient; - - constructor(cc: IConfigCatClient) { - this.client = cc; - } - - getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise { - return this.client.getValueAsync(experimentName, defaultValue, attributesToUser(attributes)); - } - - dispose(): void { - return this.client.dispose(); - } -} - -function attributesToUser(attributes: Attributes): User { - const userId = attributes.userId || ""; - const email = attributes.email || ""; - - const custom: { [key: string]: string } = {}; - if (attributes.projectId) { - custom[PROJECT_ID_ATTRIBUTE] = attributes.projectId; - } - if (attributes.teamId) { - custom[TEAM_ID_ATTRIBUTE] = attributes.teamId; - } - if (attributes.teamName) { - custom[TEAM_NAME_ATTRIBUTE] = attributes.teamName; - } - if (attributes.teams) { - custom[TEAM_NAMES_ATTRIBUTE] = attributes.teams.map((t) => t.name).join(","); - custom[TEAM_IDS_ATTRIBUTE] = attributes.teams.map((t) => t.id).join(","); - } - - return new User(userId, email, "", custom); -} diff --git a/components/gitpod-protocol/package.json b/components/gitpod-protocol/package.json index f2ab6545efb115..e7b96ddc8fc0b3 100644 --- a/components/gitpod-protocol/package.json +++ b/components/gitpod-protocol/package.json @@ -42,6 +42,7 @@ "@types/react": "17.0.32", "ajv": "^6.5.4", "analytics-node": "^6.0.0", + "configcat-node": "^7.0.0", "cookie": "^0.4.2", "express": "^4.17.3", "inversify": "^5.1.1", diff --git a/components/dashboard/src/experiments/always-default.ts b/components/gitpod-protocol/src/experiments/always-default.ts similarity index 95% rename from components/dashboard/src/experiments/always-default.ts rename to components/gitpod-protocol/src/experiments/always-default.ts index cfd8b5bb59888d..56ce7afc98675e 100644 --- a/components/dashboard/src/experiments/always-default.ts +++ b/components/gitpod-protocol/src/experiments/always-default.ts @@ -3,7 +3,7 @@ * Licensed under the GNU Affero General Public License (AGPL). * See License-AGPL.txt in the project root for license information. */ -import { Attributes, Client } from "./client"; +import { Attributes, Client } from "./types"; // AlwaysReturningDefaultValueClient is an implemention of an experiments.Client which performs no lookup/network operation // and always returns the default value for a given experimentName. diff --git a/components/gitpod-protocol/src/experiments/configcat-server.ts b/components/gitpod-protocol/src/experiments/configcat-server.ts new file mode 100644 index 00000000000000..f7f60ba86b4df9 --- /dev/null +++ b/components/gitpod-protocol/src/experiments/configcat-server.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { Client } from "./types"; +import * as configcat from "configcat-node"; +import { LogLevel } from "configcat-common"; +import { ConfigCatClient } from "./configcat"; +import { newAlwaysReturningDefaultValueClient } from "./always-default"; + +let client: Client | undefined; + +export function getExperimentsClientForBackend(): Client { + // We have already instantiated a client, we can just re-use it. + if (client !== undefined) { + return client; + } + + // Retrieve SDK key from ENV Variable + const sdkKey = process.env.CONFIGCAT_SDK_KEY; + + // Self-hosted installations do not set the ConfigCat SDK key, so always use a client which returns the default value. + if (sdkKey === undefined || sdkKey === "") { + client = newAlwaysReturningDefaultValueClient(); + return client; + } + + const configCatClient = configcat.createClient(sdkKey, { + logger: configcat.createConsoleLogger(LogLevel.Error), + maxInitWaitTimeSeconds: 0, + }); + + client = new ConfigCatClient(configCatClient); + return client; +} diff --git a/components/gitpod-protocol/src/experiments/configcat.ts b/components/gitpod-protocol/src/experiments/configcat.ts new file mode 100644 index 00000000000000..192b87a34f9220 --- /dev/null +++ b/components/gitpod-protocol/src/experiments/configcat.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { Attributes, Client } from "./types"; +import { User } from "configcat-common/lib/RolloutEvaluator"; +import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient"; + +export const PROJECT_ID_ATTRIBUTE = "project_id"; +export const TEAM_ID_ATTRIBUTE = "team_id"; +export const TEAM_IDS_ATTRIBUTE = "team_ids"; +export const TEAM_NAME_ATTRIBUTE = "team_name"; +export const TEAM_NAMES_ATTRIBUTE = "team_names"; + +export class ConfigCatClient implements Client { + private client: IConfigCatClient; + + constructor(cc: IConfigCatClient) { + this.client = cc; + } + + getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise { + return this.client.getValueAsync(experimentName, defaultValue, attributesToUser(attributes)); + } + + dispose(): void { + return this.client.dispose(); + } +} + +export function attributesToUser(attributes: Attributes): User { + const userId = attributes.userId || ""; + const email = attributes.email || ""; + + const custom: { [key: string]: string } = {}; + if (attributes.projectId) { + custom[PROJECT_ID_ATTRIBUTE] = attributes.projectId; + } + if (attributes.teamId) { + custom[TEAM_ID_ATTRIBUTE] = attributes.teamId; + } + if (attributes.teamName) { + custom[TEAM_NAME_ATTRIBUTE] = attributes.teamName; + } + if (attributes.teams) { + custom[TEAM_NAMES_ATTRIBUTE] = attributes.teams.map((t) => t.name).join(","); + custom[TEAM_IDS_ATTRIBUTE] = attributes.teams.map((t) => t.id).join(","); + } + + return new User(userId, email, "", custom); +} diff --git a/components/gitpod-protocol/src/experiments/types.ts b/components/gitpod-protocol/src/experiments/types.ts new file mode 100644 index 00000000000000..2092baabea220b --- /dev/null +++ b/components/gitpod-protocol/src/experiments/types.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { Team } from "../teams-projects-protocol"; + +// Attributes define attributes which can be used to segment audiences. +// Set the attributes which you want to use to group audiences into. +export interface Attributes { + userId?: string; + email?: string; + + // Currently selected Gitpod Project ID + projectId?: string; + + // Currently selected Gitpod Team ID + teamId?: string; + // Currently selected Gitpod Team Name + teamName?: string; + + // All the Gitpod Teams that the user is a member (or owner) of + teams?: Array; +} + +export interface Client { + getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise; + + // dispose will dispose of the client, no longer retrieving flags + dispose(): void; +} diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index f0845b696ecfc1..1f5675a9cf34b5 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -102,7 +102,7 @@ import { ClientMetadata, traceClientMetadata } from "../../../src/websocket/webs import { BitbucketAppSupport } from "../bitbucket/bitbucket-app-support"; import { URL } from "url"; import { UserCounter } from "../user/user-counter"; -import { getExperimentsClient } from "../../../src/experiments"; +import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { @@ -1860,15 +1860,12 @@ export class GitpodServerEEImpl extends GitpodServerImpl { protected async ensureIsUsageBasedFeatureFlagEnabled(user: User): Promise { const teams = await this.teamDB.findTeamsByUser(user.id); - const isUsageBasedBillingEnabled = await getExperimentsClient().getValueAsync( + const isUsageBasedBillingEnabled = await getExperimentsClientForBackend().getValueAsync( "isUsageBasedBillingEnabled", false, { - identifier: user.id, - custom: { - team_ids: teams.map((t) => t.id).join(","), - team_names: teams.map((t) => t.name).join(","), - }, + userId: user.id, + teams: teams, }, ); if (!isUsageBasedBillingEnabled) { diff --git a/components/server/src/experiments.ts b/components/server/src/experiments.ts deleted file mode 100644 index 6067039b9f9162..00000000000000 --- a/components/server/src/experiments.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2022 Gitpod GmbH. All rights reserved. - * Licensed under the GNU Affero General Public License (AGPL). - * See License-AGPL.txt in the project root for license information. - */ - -import * as configcat from "configcat-node"; -import { IConfigCatClient } from "configcat-common/lib/ConfigCatClient"; - -let logger = configcat.createConsoleLogger(3); // Setting log level to 3 (Info) -let client: IConfigCatClient | undefined; - -export function getExperimentsClient(): IConfigCatClient { - if (client === undefined) { - client = configcat.createClient("WBLaCPtkjkqKHlHedziE9g/LEAOCNkbuUKiqUZAcVg7dw", { - // <-- This is the actual SDK Key for your Test environment - maxInitWaitTimeSeconds: 0, - logger: logger, - }); - } - - return client; -} diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index af8caede67e280..acd04fcab93be0 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -161,7 +161,6 @@ import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider"; import { LicenseEvaluator } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; -import { getExperimentsClient } from "../experiments"; import { Currency } from "@gitpod/gitpod-protocol/lib/plans"; // shortcut @@ -2142,19 +2141,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { await this.guardTeamOperation(params.teamId, "get"); } - const isFeatureEnabled = await getExperimentsClient().getValueAsync("isMyFirstFeatureEnabled", false, { - identifier: user.id, - custom: { - project_name: params.name, - }, - }); - if (isFeatureEnabled) { - throw new ResponseError( - ErrorCodes.NOT_FOUND, - `Feature is disabled for this user or project - sample usage of experiements`, - ); - } - const project = this.projectsService.createProject(params, user); this.analytics.track({ userId: user.id, diff --git a/yarn.lock b/yarn.lock index ab73baca79257e..9134e633c4d3ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5834,6 +5834,11 @@ configcat-common@^4.6.1, configcat-common@^4.6.2: resolved "https://registry.yarnpkg.com/configcat-common/-/configcat-common-4.6.2.tgz#ef37114cf77c10378e686078bc45903508e0ed49" integrity sha512-o7qp5xb3K9w3tL0dK0g2/IwzOym4SOcdl+Hgh7d1125fKamDk8Jg6nBb+jEkA0qs0msYI+kpcL7pEsihYUhEDg== +configcat-common@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/configcat-common/-/configcat-common-6.0.0.tgz#ccdb9bdafcb6a89144cac17faaab60ac960fed2a" + integrity sha512-C/lCeTKiFk9kPElRF3f4zIkvVCLKgPJuzrKbIMHCru89mvfH5t4//hZ9TW8wPJOAje6xB6ZALutDiIxggwUvWA== + configcat-js@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/configcat-js/-/configcat-js-5.7.2.tgz#2466269f941c8564c0d2670ffbc74dc0657f1450" @@ -5850,6 +5855,15 @@ configcat-node@^6.7.1: got "^9.6.0" tunnel "0.0.6" +configcat-node@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/configcat-node/-/configcat-node-7.0.0.tgz#50dc0f8fc866d7017e93fa3d478b7a3fe08ec815" + integrity sha512-zPU+6d/uHP4SxpBgdduI5AiPetAByGnBrF+Chpg+CuC2EmY6N4qKe5f8eWaot3NE+X4m5ylq1OMy6Li9ePIb6w== + dependencies: + configcat-common "^6.0.0" + got "^9.6.0" + tunnel "0.0.6" + configstore@^5.0.0, configstore@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"