diff --git a/components/dashboard/package.json b/components/dashboard/package.json index 964659327f970b..6eb8f459a25f77 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -5,6 +5,7 @@ "private": true, "dependencies": { "@gitpod/gitpod-protocol": "0.1.5", + "configcat-js": "^5.7.2", "countries-list": "^2.6.1", "js-cookie": "^3.0.1", "moment": "^2.29.1", diff --git a/components/dashboard/src/experiments/always-default.ts b/components/dashboard/src/experiments/always-default.ts new file mode 100644 index 00000000000000..cfd8b5bb59888d --- /dev/null +++ b/components/dashboard/src/experiments/always-default.ts @@ -0,0 +1,24 @@ +/** + * 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 "./client"; + +// AlwaysReturningDefaultValueClient is an implemention of an experiments.Client which performs no lookup/network operation +// and always returns the default value for a given experimentName. +// This client is used for non-SaaS version of Gitpod, in particular for self-hosted installations where external +// network connections are not desirable or even possible. +class AlwaysReturningDefaultValueClient implements Client { + getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise { + return Promise.resolve(defaultValue); + } + + dispose(): void { + // there is nothing to dispose, no-op. + } +} + +export function newAlwaysReturningDefaultValueClient(): Client { + return new AlwaysReturningDefaultValueClient(); +} diff --git a/components/dashboard/src/experiments/client.ts b/components/dashboard/src/experiments/client.ts new file mode 100644 index 00000000000000..daa1d0398b265e --- /dev/null +++ b/components/dashboard/src/experiments/client.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +// Attributes define attributes which can be used to segment audiences. +// Set the attributes which you want to use to group audiences into. +import { newNonProductionConfigCatClient, newProductionConfigCatClient } from "./configcat"; +import { newAlwaysReturningDefaultValueClient } from "./always-default"; + +export interface Attributes { + userID?: string; + email?: string; + + // Gitpod Project ID + projectID?: string; + + // Gitpod Team ID + teamID?: string; + // Gitpod Team Name + teamName?: string; +} + +export interface Client { + getValueAsync(experimentName: string, defaultValue: T, attributes: Attributes): Promise; + + // dispose will dispose of the client, no longer retrieving flags + dispose(): void; +} + +let client: Client | undefined; + +export function getExperimentsClient(): Client { + // We have already instantiated a client, we can just re-use it. + if (client !== undefined) { + return client; + } + + const host = window.location.hostname; + if (host === "gitpod.io") { + client = newProductionConfigCatClient(); + } else if (host === "gitpod-staging.com" || host.endsWith("gitpod-dev.com") || host.endsWith("gitpod-io-dev.com")) { + client = newNonProductionConfigCatClient(); + } else { + // We're gonna use a client which always returns the default value. + client = newAlwaysReturningDefaultValueClient(); + } + + return client; +} + +export const PROJECT_ID_ATTRIBUTE = "project_id"; +export const TEAM_ID_ATTRIBUTE = "team_id"; +export const TEAM_NAME_ATTRIBUTE = "team_name"; diff --git a/components/dashboard/src/experiments/configcat.ts b/components/dashboard/src/experiments/configcat.ts new file mode 100644 index 00000000000000..397b71aff0c00d --- /dev/null +++ b/components/dashboard/src/experiments/configcat.ts @@ -0,0 +1,69 @@ +/** + * 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_ID_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, { + 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, { + 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; + } + + return new User(userID, email, "", custom); +} diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 2ca855c2187b9c..e8cbb279d1cc37 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -18,6 +18,7 @@ import { User } from "@gitpod/gitpod-protocol"; import { useLocation } from "react-router"; import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./start-workspace-modal-context"; import SelectIDEModal from "../settings/SelectIDEModal"; +import { getExperimentsClient } from "../experiments/client"; export interface WorkspacesProps {} @@ -36,6 +37,7 @@ export default function () { const [inactiveWorkspaces, setInactiveWorkspaces] = useState([]); const [workspaceModel, setWorkspaceModel] = useState(); const { setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); + const [isExperimentEnabled, setExperiment] = useState(false); useEffect(() => { (async () => { @@ -45,13 +47,22 @@ export default function () { }, [teams, location]); const isOnboardingUser = user && User.isOnboardingUser(user); + useEffect(() => { + (async () => { + if (teams && teams.length > 0) { + const isEnabled = await getExperimentsClient().getValueAsync("isMyFirstFeatureEnabled", false, { + teamName: teams[0]?.name, + }); + setExperiment(isEnabled); + } + })(); + }, [teams]); + console.log("Is experiment enabled? ", isExperimentEnabled); return ( <>
- {isOnboardingUser && } - {workspaceModel?.initialized && (activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || workspaceModel.searchTerm ? ( <> diff --git a/components/server/package.json b/components/server/package.json index 0117d9da5ab808..dce5f3a1d7bc00 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -46,6 +46,7 @@ "base-64": "^1.0.0", "bitbucket": "^2.7.0", "body-parser": "^1.19.2", + "configcat-node": "^6.7.1", "cookie": "^0.4.2", "cookie-parser": "^1.4.6", "cors": "^2.8.4", diff --git a/components/server/src/experiments.ts b/components/server/src/experiments.ts new file mode 100644 index 00000000000000..e428b83aed7048 --- /dev/null +++ b/components/server/src/experiments.ts @@ -0,0 +1,22 @@ +/** + * 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 + 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 7b81f3d7ad96ba..7e0ed826900885 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -161,6 +161,7 @@ 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"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -2135,6 +2136,20 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { // Anyone who can read a team's information (i.e. any team member) can create a new project. 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 6e984daa40821f..a552e851949c94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5813,6 +5813,27 @@ concurrently@^6.2.1: tree-kill "^1.2.2" yargs "^16.2.0" +configcat-common@^4.6.1, configcat-common@^4.6.2: + version "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-js@^5.7.2: + version "5.7.2" + resolved "https://registry.yarnpkg.com/configcat-js/-/configcat-js-5.7.2.tgz#2466269f941c8564c0d2670ffbc74dc0657f1450" + integrity sha512-Pvryi3y1z1ZyhId5fGv6Weel6YU6EuTHHYdfY1SOaVSvNeXNU9HwLpzMUCwdINtSXyxtHd0xUMumRUje2h7/Ng== + dependencies: + configcat-common "^4.6.2" + +configcat-node@^6.7.1: + version "6.7.1" + resolved "https://registry.yarnpkg.com/configcat-node/-/configcat-node-6.7.1.tgz#87c6be569d646575c969d00966a97416a4b6a4fa" + integrity sha512-+tdKZkrWo3JdRezU90daly9LmL2efCDTnjHlKMUpwtVjrNjPVXggrydrgB5QUKmJUspWUd9bFSXS3jQgoGpB4g== + dependencies: + configcat-common "^4.6.1" + 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" @@ -17078,6 +17099,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tunnel@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" + integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"