From 4a992e626cf0baaa7e4f556eb68e9d4568859f31 Mon Sep 17 00:00:00 2001 From: Mikael Hoegqvist Tabor Date: Wed, 2 Aug 2023 20:31:57 +0200 Subject: [PATCH] chore(analytics): optionally include user and project metadata for noProject commands --- core/src/analytics/analytics.ts | 85 ++++++++++++++---- core/test/unit/src/analytics/analytics.ts | 102 +++++++++++++--------- core/test/unit/src/cli/analytics.ts | 6 +- 3 files changed, 134 insertions(+), 59 deletions(-) diff --git a/core/src/analytics/analytics.ts b/core/src/analytics/analytics.ts index 3bd5dcfe4c6..a2fcf75b8b1 100644 --- a/core/src/analytics/analytics.ts +++ b/core/src/analytics/analytics.ts @@ -15,18 +15,20 @@ import { getPackageVersion, sleep, getDurationMsec } from "../util/util" import { SEGMENT_PROD_API_KEY, SEGMENT_DEV_API_KEY, gardenEnv } from "../constants" import { Log } from "../logger/log-entry" import hasha = require("hasha") -import { Garden } from "../garden" +import { DummyGarden, Garden } from "../garden" import { AnalyticsCommandResult, AnalyticsEventType } from "./analytics-types" import dedent from "dedent" import { getGitHubUrl } from "../docs/common" import { Profile } from "../util/profiling" import { ModuleConfig } from "../config/module" -import { UserResult } from "@garden-io/platform-api-types" import { uuidv4 } from "../util/random" import { GardenBaseError, GardenErrorContext, StackTraceMetadata } from "../exceptions" import { ActionConfigMap } from "../actions/types" import { actionKinds } from "../actions/types" import { getResultErrorProperties } from "./helpers" +import { findProjectConfig } from "../config/base" +import { ProjectResource } from "../config/project" +import { CloudApi, CloudUserProfile, getGardenCloudDomain } from "../cloud/api" const CI_USER = "ci-user" @@ -113,6 +115,7 @@ interface PropertiesBase { isLoggedIn: boolean cloudUserId?: string customer?: string + organizationName?: string ciName: string | null system: SystemInfo isCI: boolean @@ -197,6 +200,7 @@ interface IdentifyEvent { traits: { userIdV2: string customer?: string + organizationName?: string platform: string platformVersion: string gardenVersion: string @@ -253,8 +257,10 @@ export class AnalyticsHandler { private enterpriseProjectIdV2?: string private enterpriseDomainV2?: string private isLoggedIn: boolean + // These are set for a logged in user private cloudUserId?: string - private cloudCustomerName?: string + private cloudOrganizationName?: string + private cloudDomain?: string private ciName: string | null private systemConfig: SystemInfo private isCI: boolean @@ -275,6 +281,8 @@ export class AnalyticsHandler { cloudUser, isEnabled, ciInfo, + projectName, + fallbackCloudDomain, }: { garden: Garden log: Log @@ -283,8 +291,10 @@ export class AnalyticsHandler { moduleConfigs: ModuleConfig[] actionConfigs: ActionConfigMap isEnabled: boolean - cloudUser?: UserResult + cloudUser?: CloudUserProfile ciInfo: CiInfo + projectName: string + fallbackCloudDomain?: string }) { const segmentClient = require("analytics-node") const segmentApiKey = gardenEnv.ANALYTICS_DEV ? SEGMENT_DEV_API_KEY : SEGMENT_PROD_API_KEY @@ -335,10 +345,10 @@ export class AnalyticsHandler { const originName = this.garden.vcsInfo.originUrl - const projectName = this.garden.projectName this.projectName = AnalyticsHandler.hash(projectName) this.projectNameV2 = AnalyticsHandler.hashV2(projectName) + // Note, this is not the project id from the Project config, its referred to as enterpriseProjectId below const projectId = originName || this.projectName this.projectId = AnalyticsHandler.hash(projectId) this.projectIdV2 = AnalyticsHandler.hashV2(projectId) @@ -347,20 +357,22 @@ export class AnalyticsHandler { // in the project level Garden configuration. Not to be confused with the anonymized project ID we generate from // the project name for the purpose of analytics. const enterpriseProjectId = this.garden.projectId - if (enterpriseProjectId) { + const enterpriseDomain = this.garden.cloudDomain + + // we only set this when defined in the project config since it indicates + // that the project has been connected to a cloud project + if (enterpriseProjectId && enterpriseDomain) { this.enterpriseProjectId = AnalyticsHandler.hash(enterpriseProjectId) this.enterpriseProjectIdV2 = AnalyticsHandler.hashV2(enterpriseProjectId) - } - - const enterpriseDomain = this.garden.cloudDomain - if (enterpriseDomain) { this.enterpriseDomain = AnalyticsHandler.hash(enterpriseDomain) this.enterpriseDomainV2 = AnalyticsHandler.hashV2(enterpriseDomain) } + // A user can be logged in to the community tier if (cloudUser) { this.cloudUserId = AnalyticsHandler.makeCloudUserId(cloudUser) - this.cloudCustomerName = cloudUser.organization.name + this.cloudOrganizationName = cloudUser.organizationName + this.cloudDomain = cloudUser.domain } this.isRecurringUser = getIsRecurringUser(analyticsConfig.firstRunAt, analyticsConfig.latestRunAt) @@ -371,7 +383,8 @@ export class AnalyticsHandler { anonymousId: anonymousUserId, traits: { userIdV2, - customer: cloudUser?.organization.name, + customer: cloudUser?.organizationName, + organizationName: cloudUser?.organizationName, platform: platform(), platformVersion: release(), gardenVersion: getPackageVersion(), @@ -429,15 +442,50 @@ export class AnalyticsHandler { const moduleConfigs = await garden.getRawModuleConfigs() const actionConfigs = await garden.getRawActionConfigs() - let cloudUser: UserResult | undefined + let cloudUser: CloudUserProfile | undefined if (garden.cloudApi) { try { - cloudUser = await garden.cloudApi?.getProfile() + const userProfile = await garden.cloudApi?.getProfile() + + if (userProfile && userProfile.id && userProfile.organization.name) { + cloudUser = { + userId: userProfile.id, + organizationName: userProfile.organization.name, + domain: garden.cloudApi.domain, + } + } } catch (err) { log.debug(`Getting profile from API failed with error: ${err.message}`) } } + // best effort load the project if this is a dummy garden instance + let projectName = garden.projectName + let fallbackCloudDomain: string | undefined + + // Not logged in and this is a dummy instance, try to best effort retrieve + // the user and project metadata + if (!garden.cloudApi && garden instanceof DummyGarden) { + const config: ProjectResource | undefined = await findProjectConfig({ log, path: garden.projectRoot }) + + if (config) { + fallbackCloudDomain = getGardenCloudDomain(config.domain) + // override the project name since it will default to no-project + projectName = config.name + + // fallback to the stored user profile (this is done without verifying the token and the content) + const userProfile = await CloudApi.getAuthTokenUserProfile(log, garden.globalConfigStore, fallbackCloudDomain) + + if (userProfile) { + cloudUser = { + userId: userProfile.userId, + organizationName: userProfile.organizationName, + domain: fallbackCloudDomain, + } + } + } + } + if (isFirstRun && !ciInfo.isCi) { const gitHubUrl = getGitHubUrl("docs/misc/telemetry.md") const msg = dedent` @@ -486,6 +534,8 @@ export class AnalyticsHandler { isEnabled, ciInfo, anonymousUserId, + projectName, + fallbackCloudDomain, }) } @@ -513,8 +563,8 @@ export class AnalyticsHandler { } } - static makeCloudUserId(cloudUser: UserResult) { - return `${cloudUser.organization.name}_${cloudUser.id}` + static makeCloudUserId(cloudUser: CloudUserProfile) { + return `${cloudUser.organizationName}_${cloudUser.userId}` } /** @@ -532,7 +582,8 @@ export class AnalyticsHandler { enterpriseDomainV2: this.enterpriseDomainV2, isLoggedIn: this.isLoggedIn, ciName: this.ciName, - customer: this.cloudCustomerName, + customer: this.cloudOrganizationName, + organizationName: this.cloudOrganizationName, system: this.systemConfig, isCI: this.isCI, sessionId: this.sessionId, diff --git a/core/test/unit/src/analytics/analytics.ts b/core/test/unit/src/analytics/analytics.ts index 38734680889..aa6d647ecae 100644 --- a/core/test/unit/src/analytics/analytics.ts +++ b/core/test/unit/src/analytics/analytics.ts @@ -14,12 +14,7 @@ import { validate as validateUuid } from "uuid" import { makeTestGardenA, TestGarden, enableAnalytics, getDataDir, makeTestGarden, freezeTime } from "../../../helpers" import { FakeCloudApi, apiProjectName, apiRemoteOriginUrl } from "../../../helpers/api" import { AnalyticsHandler, CommandResultEvent, getAnonymousUserId } from "../../../../src/analytics/analytics" -import { - DEFAULT_BUILD_TIMEOUT_SEC, - DEFAULT_GARDEN_CLOUD_DOMAIN, - GardenApiVersion, - gardenEnv, -} from "../../../../src/constants" +import { DEFAULT_BUILD_TIMEOUT_SEC, GardenApiVersion, gardenEnv } from "../../../../src/constants" import { LogLevel, RootLogger } from "../../../../src/logger/logger" import { AnalyticsGlobalConfig } from "../../../../src/config-store/global" import { QuietWriter } from "../../../../src/logger/writers/quiet-writer" @@ -31,8 +26,6 @@ const host = "https://api.segment.io" const projectNameV2 = "discreet-sudden-struggle_95048f63dc14db38ed4138ffb6ff8999" describe("AnalyticsHandler", () => { - const scope = nock(host) - const time = new Date() const basicConfig: AnalyticsGlobalConfig = { anonymousUserId: "6d87dd61-0feb-4373-8c78-41cd010907e7", @@ -50,7 +43,13 @@ describe("AnalyticsHandler", () => { ciName: null, } + function setupNock() { + return nock(host) + } + before(async () => { + // make sure we don't do any external requests + nock.disableNetConnect() garden = await makeTestGardenA() resetAnalyticsConfig = await enableAnalytics(garden) }) @@ -58,6 +57,7 @@ describe("AnalyticsHandler", () => { after(async () => { await resetAnalyticsConfig() nock.cleanAll() + nock.enableNetConnect() }) describe("factory", () => { @@ -148,7 +148,7 @@ describe("AnalyticsHandler", () => { }) it("should identify the user with an anonymous ID", async () => { let payload: any - scope + const scope = setupNock() .post(`/v1/batch`, (body) => { const events = body.batch.map((event: any) => event.type) payload = body.batch @@ -193,7 +193,7 @@ describe("AnalyticsHandler", () => { }) it("should not identify the user if analytics is disabled", async () => { let payload: any - scope + const scope = setupNock() .post(`/v1/batch`, (body) => { const events = body.batch.map((event: any) => event.type) payload = body.batch @@ -258,6 +258,8 @@ describe("AnalyticsHandler", () => { }) it("should not replace the anonymous user ID with the Cloud user ID", async () => { + setupNock().post(`/v1/batch`).reply(200) + await garden.globalConfigStore.set("analytics", basicConfig) const now = freezeTime() @@ -273,6 +275,8 @@ describe("AnalyticsHandler", () => { }) }) it("should be enabled unless env var for disabling is set", async () => { + setupNock().post(`/v1/batch`).reply(200) + await garden.globalConfigStore.set("analytics", basicConfig) analytics = await AnalyticsHandler.factory({ garden, log: garden.log, ciInfo }) const isEnabledWhenNoEnvVar = analytics.isEnabled @@ -291,7 +295,7 @@ describe("AnalyticsHandler", () => { }) it("should identify the user with a Cloud ID", async () => { let payload: any - scope + const scope = setupNock() .post(`/v1/batch`, (body) => { const events = body.batch.map((event: any) => event.type) payload = body.batch @@ -303,17 +307,28 @@ describe("AnalyticsHandler", () => { await garden.globalConfigStore.set("analytics", basicConfig) analytics = await AnalyticsHandler.factory({ garden, log: garden.log, ciInfo }) + const newConfig = await garden.globalConfigStore.get("analytics") + + expect(newConfig).to.eql({ + anonymousUserId: "6d87dd61-0feb-4373-8c78-41cd010907e7", + firstRunAt: basicConfig.firstRunAt, + latestRunAt: now, + optedOut: false, + cloudProfileEnabled: true, + }) + await analytics.flush() expect(analytics.isEnabled).to.equal(true) expect(scope.isDone()).to.equal(true) expect(payload).to.eql([ { - userId: "garden_1", // This is the imporant part + userId: "garden_1", // This is the important part anonymousId: "6d87dd61-0feb-4373-8c78-41cd010907e7", traits: { userIdV2: AnalyticsHandler.hashV2("6d87dd61-0feb-4373-8c78-41cd010907e7"), customer: "garden", + organizationName: "garden", platform: payload[0].traits.platform, platformVersion: payload[0].traits.platformVersion, gardenVersion: payload[0].traits.gardenVersion, @@ -333,7 +348,7 @@ describe("AnalyticsHandler", () => { }) it("should not identify the user if analytics is disabled via env var", async () => { let payload: any - scope + const scope = setupNock() .post(`/v1/batch`, (body) => { const events = body.batch.map((event: any) => event.type) payload = body.batch @@ -368,7 +383,7 @@ describe("AnalyticsHandler", () => { }) it("should return the event with the correct project metadata", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) await garden.globalConfigStore.set("analytics", basicConfig) const now = freezeTime() @@ -385,10 +400,11 @@ describe("AnalyticsHandler", () => { projectNameV2, enterpriseProjectId: undefined, enterpriseProjectIdV2: undefined, - enterpriseDomain: AnalyticsHandler.hash(DEFAULT_GARDEN_CLOUD_DOMAIN), - enterpriseDomainV2: AnalyticsHandler.hashV2(DEFAULT_GARDEN_CLOUD_DOMAIN), + enterpriseDomain: undefined, + enterpriseDomainV2: undefined, isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -413,7 +429,7 @@ describe("AnalyticsHandler", () => { }) }) it("should set the CI info if applicable", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) await garden.globalConfigStore.set("analytics", basicConfig) const now = freezeTime() @@ -430,10 +446,11 @@ describe("AnalyticsHandler", () => { projectNameV2, enterpriseProjectId: undefined, enterpriseProjectIdV2: undefined, - enterpriseDomain: AnalyticsHandler.hash(DEFAULT_GARDEN_CLOUD_DOMAIN), - enterpriseDomainV2: AnalyticsHandler.hashV2(DEFAULT_GARDEN_CLOUD_DOMAIN), + enterpriseDomain: undefined, + enterpriseDomainV2: undefined, isLoggedIn: false, customer: undefined, + organizationName: undefined, system: analytics["systemConfig"], isCI: true, ciName: "foo", @@ -458,7 +475,7 @@ describe("AnalyticsHandler", () => { }) }) it("should handle projects with no services, tests, or tasks", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) garden.setModuleConfigs([ { @@ -492,10 +509,11 @@ describe("AnalyticsHandler", () => { projectNameV2, enterpriseProjectId: undefined, enterpriseProjectIdV2: undefined, - enterpriseDomain: AnalyticsHandler.hash(DEFAULT_GARDEN_CLOUD_DOMAIN), - enterpriseDomainV2: AnalyticsHandler.hashV2(DEFAULT_GARDEN_CLOUD_DOMAIN), + enterpriseDomain: undefined, + enterpriseDomainV2: undefined, isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -519,8 +537,8 @@ describe("AnalyticsHandler", () => { }, }) }) - it("should include enterprise metadata", async () => { - scope.post(`/v1/batch`).reply(200) + it("should include enterprise metadata from config", async () => { + setupNock().post(`/v1/batch`).reply(200) const root = getDataDir("test-projects", "login", "has-domain-and-id") garden = await makeTestGarden(root) @@ -546,6 +564,7 @@ describe("AnalyticsHandler", () => { enterpriseProjectIdV2: AnalyticsHandler.hashV2("dummy-id"), isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -570,7 +589,7 @@ describe("AnalyticsHandler", () => { }) }) it("should override the parentSessionId", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) const root = getDataDir("test-projects", "login", "has-domain-and-id") garden = await makeTestGarden(root) @@ -596,6 +615,7 @@ describe("AnalyticsHandler", () => { enterpriseProjectIdV2: AnalyticsHandler.hashV2("dummy-id"), isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -620,7 +640,7 @@ describe("AnalyticsHandler", () => { }) }) it("should have counts for action kinds", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) const root = getDataDir("test-projects", "config-templates") garden = await makeTestGarden(root) @@ -640,12 +660,13 @@ describe("AnalyticsHandler", () => { projectIdV2: AnalyticsHandler.hashV2(apiRemoteOriginUrl), projectName: AnalyticsHandler.hash("config-templates"), projectNameV2: AnalyticsHandler.hashV2("config-templates"), - enterpriseDomain: AnalyticsHandler.hash(DEFAULT_GARDEN_CLOUD_DOMAIN), - enterpriseDomainV2: AnalyticsHandler.hashV2(DEFAULT_GARDEN_CLOUD_DOMAIN), + enterpriseDomain: undefined, + enterpriseDomainV2: undefined, enterpriseProjectId: undefined, enterpriseProjectIdV2: undefined, isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -685,7 +706,7 @@ describe("AnalyticsHandler", () => { }) it("should return the event as a success", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) await garden.globalConfigStore.set("analytics", basicConfig) @@ -712,10 +733,11 @@ describe("AnalyticsHandler", () => { projectNameV2, enterpriseProjectId: undefined, enterpriseProjectIdV2: undefined, - enterpriseDomain: AnalyticsHandler.hash(DEFAULT_GARDEN_CLOUD_DOMAIN), - enterpriseDomainV2: AnalyticsHandler.hashV2(DEFAULT_GARDEN_CLOUD_DOMAIN), + enterpriseDomain: undefined, + enterpriseDomainV2: undefined, isLoggedIn: false, customer: undefined, + organizationName: undefined, ciName: analytics["ciName"], system: analytics["systemConfig"], isCI: analytics["isCI"], @@ -740,7 +762,7 @@ describe("AnalyticsHandler", () => { }) }) it("should return the event as a failure with nested error metadata", async () => { - scope.post(`/v1/batch`).reply(200) + setupNock().post(`/v1/batch`).reply(200) await garden.globalConfigStore.set("analytics", basicConfig) @@ -816,7 +838,7 @@ describe("AnalyticsHandler", () => { }) }) it("should return the event as a failure with multiple errors", async () => { - scope.post(`/v1/batch`).reply(200) + const scope = setupNock().post(`/v1/batch`).reply(200) await garden.globalConfigStore.set("analytics", basicConfig) @@ -871,11 +893,13 @@ describe("AnalyticsHandler", () => { // That's why there are usually two mock requests per test below. describe("flush", () => { const getEvents = (body: any) => - body.batch.map((event: any) => ({ - event: event.event, - type: event.type, - name: event.properties.name, - })) + body.batch + .filter((event: any) => event.type === "track") + .map((event: any) => ({ + event: event.event, + type: event.type, + name: event.properties.name, + })) beforeEach(async () => { garden = await makeTestGardenA() @@ -890,7 +914,7 @@ describe("AnalyticsHandler", () => { }) it("should wait for pending events on network delays", async () => { - scope + const scope = setupNock() .post(`/v1/batch`, (body) => { // Assert that the event batch contains a single "track" event return isEqual(getEvents(body), [ diff --git a/core/test/unit/src/cli/analytics.ts b/core/test/unit/src/cli/analytics.ts index c21cafbbf54..ad3b2ddfd05 100644 --- a/core/test/unit/src/cli/analytics.ts +++ b/core/test/unit/src/cli/analytics.ts @@ -51,7 +51,7 @@ describe("cli analytics", () => { } } - it.skip("should access the version check service", async () => { + it("should access the version check service", async () => { const scope = nock("https://get.garden.io") scope.get("/version").query(true).reply(200) @@ -63,7 +63,7 @@ describe("cli analytics", () => { expect(scope.done()).to.not.throw }) - it.skip("should wait for queued analytic events to flush", async () => { + it("should wait for queued analytic events to flush", async () => { const scope = nock("https://api.segment.io") // Each command run result in two events: @@ -99,7 +99,7 @@ describe("cli analytics", () => { expect(scope.done()).to.not.throw }) - it.skip("should not send analytics if disabled for command", async () => { + it("should not send analytics if disabled for command", async () => { const scope = nock("https://api.segment.io") scope.post(`/v1/batch`).reply(201)