Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(analytics): include project and user metadata when running commands with noProject #4915

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
6 changes: 3 additions & 3 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@
"is-subset": "^0.1.1",
"md5": "^2.3.0",
"mocha": "^10.2.0",
"nock": "^12.0.3",
"nock": "^13.3.2",
"node-fetch": "^2.7.0",
"nodemon": "^3.0.1",
"nyc": "^15.1.0",
Expand Down Expand Up @@ -279,8 +279,8 @@
"integ-local": "GARDEN_INTEG_TEST_MODE=local GARDEN_SKIP_TESTS=\"remote-only\" npm run _integ",
"integ-minikube": "GARDEN_INTEG_TEST_MODE=local GARDEN_SKIP_TESTS=\"remote-only\" npm run _integ",
"integ-remote": "GARDEN_INTEG_TEST_MODE=remote GARDEN_SKIP_TESTS=local-only npm run _integ",
"test": "mocha",
"test:silly": "GARDEN_LOGGER_TYPE=basic GARDEN_LOG_LEVEL=silly mocha"
"test": "NODE_OPTIONS=--no-experimental-fetch mocha",
"test:silly": "NODE_OPTIONS=--no-experimental-fetch GARDEN_LOGGER_TYPE=basic GARDEN_LOG_LEVEL=silly mocha"
},
"pkg": {
"scripts": [
Expand Down
133 changes: 107 additions & 26 deletions core/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ 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 { GardenError, NodeJSErrnoErrorCodes, StackTraceMetadata } from "../exceptions"
import { ActionConfigMap } from "../actions/types"
import { actionKinds } from "../actions/types"
import { getResultErrorProperties } from "./helpers"
import segmentClient = require("analytics-node")
import { findProjectConfig } from "../config/base"
import { ProjectConfig } from "../config/project"
import { CloudApi, CloudUserProfile, getGardenCloudDomain } from "../cloud/api"

const CI_USER = "ci-user"

Expand Down Expand Up @@ -113,7 +115,9 @@ interface PropertiesBase {
enterpriseDomainV2?: string
isLoggedIn: boolean
cloudUserId?: string
cloudDomain?: string
customer?: string
organizationName?: string
ciName: string | null
system: SystemInfo
isCI: boolean
Expand Down Expand Up @@ -214,6 +218,7 @@ interface IdentifyEvent {
traits: {
userIdV2: string
customer?: string
organizationName?: string
platform: string
platformVersion: string
gardenVersion: string
Expand Down Expand Up @@ -270,13 +275,15 @@ 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
private sessionId: string
private pendingEvents: Map<string, SegmentEvent>
private pendingEvents: Map<string, SegmentEvent | IdentifyEvent>
protected garden: Garden
private projectMetadata: ProjectMetadata
public isEnabled: boolean
Expand All @@ -292,6 +299,9 @@ export class AnalyticsHandler {
cloudUser,
isEnabled,
ciInfo,
projectName,
configuredCloudDomain,
configuredCloudProjectId,
}: {
garden: Garden
log: Log
Expand All @@ -300,8 +310,11 @@ export class AnalyticsHandler {
moduleConfigs: ModuleConfig[]
actionConfigs: ActionConfigMap
isEnabled: boolean
cloudUser?: UserResult
cloudUser?: CloudUserProfile
ciInfo: CiInfo
projectName: string
configuredCloudDomain?: string
configuredCloudProjectId?: string
}) {
const segmentApiKey = gardenEnv.ANALYTICS_DEV ? SEGMENT_DEV_API_KEY : SEGMENT_PROD_API_KEY

Expand Down Expand Up @@ -351,43 +364,43 @@ 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)

// The enterprise project ID is the UID for this project in Garden Cloud that the user puts
// 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) {
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)
if (configuredCloudProjectId && configuredCloudDomain) {
this.enterpriseProjectId = AnalyticsHandler.hash(configuredCloudProjectId)
this.enterpriseProjectIdV2 = AnalyticsHandler.hashV2(configuredCloudProjectId)
this.enterpriseDomain = AnalyticsHandler.hash(configuredCloudDomain)
this.enterpriseDomainV2 = AnalyticsHandler.hashV2(configuredCloudDomain)
}

// A user can be logged in to the community tier
if (cloudUser) {
this.cloudUserId = AnalyticsHandler.makeCloudUserId(cloudUser)
this.cloudCustomerName = cloudUser.organization.name
this.cloudUserId = AnalyticsHandler.makeUniqueCloudUserId(cloudUser)
this.cloudOrganizationName = cloudUser.organizationName
this.cloudDomain = this.garden.cloudDomain
this.isLoggedIn = true
}

this.isRecurringUser = getIsRecurringUser(analyticsConfig.firstRunAt, analyticsConfig.latestRunAt)

const userIdV2 = AnalyticsHandler.hashV2(anonymousUserId)

this.identify({
userId: this.cloudUserId,
anonymousId: anonymousUserId,
traits: {
userIdV2,
customer: cloudUser?.organization.name,
customer: cloudUser?.organizationName,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of an awkward name. Should we skip this for non-enterprise?

organizationName: cloudUser?.organizationName,
platform: platform(),
platformVersion: release(),
gardenVersion: getPackageVersion(),
Expand Down Expand Up @@ -445,15 +458,58 @@ 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}`)
}
}

// best effort load the project if this is a dummy garden instance
let projectName = garden.projectName

let projectConfig: ProjectConfig | undefined

if (garden instanceof DummyGarden) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract this to a separate function and update the control flow so that it doesn't have all these nested if statements?

We can instead return early with something like:

if (!projectConfig) {
  return
}

// proceed...

// Not logged in and this is a dummy instance, try to best effort retrieve the config
projectConfig = await findProjectConfig({ log, path: garden.projectRoot })

// override the project name since it will default to no-project
if (projectConfig) {
projectName = projectConfig.name

if (!garden.cloudApi) {
const fallbackCloudDomain = getGardenCloudDomain(projectConfig.domain)

// 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,
}
}
}
}
} else {
projectConfig = garden.getProjectConfig()
}

const configuredCloudDomain = projectConfig?.domain
const configuredCloudProjectId = projectConfig?.id

if (isFirstRun && !ciInfo.isCi) {
const gitHubUrl = getGitHubUrl("docs/misc/telemetry.md")
const msg = dedent`
Expand Down Expand Up @@ -502,6 +558,9 @@ export class AnalyticsHandler {
isEnabled,
ciInfo,
anonymousUserId,
projectName,
configuredCloudDomain,
configuredCloudProjectId,
})
}

Expand Down Expand Up @@ -529,8 +588,8 @@ export class AnalyticsHandler {
}
}

static makeCloudUserId(cloudUser: UserResult) {
return `${cloudUser.organization.name}_${cloudUser.id}`
static makeUniqueCloudUserId(cloudUser: CloudUserProfile) {
return `${cloudUser.organizationName}_${cloudUser.userId}`
}

/**
Expand All @@ -547,8 +606,11 @@ export class AnalyticsHandler {
enterpriseDomain: this.enterpriseDomain,
enterpriseDomainV2: this.enterpriseDomainV2,
isLoggedIn: this.isLoggedIn,
cloudUserId: this.cloudUserId,
cloudDomain: this.cloudDomain,
ciName: this.ciName,
customer: this.cloudCustomerName,
customer: this.cloudOrganizationName,
organizationName: this.cloudOrganizationName,
system: this.systemConfig,
isCI: this.isCI,
sessionId: this.sessionId,
Expand Down Expand Up @@ -607,7 +669,20 @@ export class AnalyticsHandler {
if (!this.segment || !this.isEnabled) {
return false
}
this.segment.identify(event)

const eventUid = uuidv4()
this.pendingEvents.set(eventUid, event)
this.segment.identify(event, (err: any) => {
this.pendingEvents.delete(eventUid)

this.log.silly(dedent`Tracking identify event.
Payload:
${JSON.stringify(event)}
`)
if (err && this.log) {
this.log.debug(`Error sending identify tracking event: ${err}`)
}
})
return event
}

Expand Down Expand Up @@ -774,7 +849,13 @@ export class AnalyticsHandler {
if (this.pendingEvents.size === 0 || retry >= 3) {
if (this.pendingEvents.size > 0) {
const pendingEvents = Array.from(this.pendingEvents.values())
.map((event) => event.event)
.map((event: SegmentEvent | IdentifyEvent) => {
if ("event" in event) {
return event.event
} else {
return event
}
})
.join(", ")
this.log.debug(`Timed out while waiting for events to flush: ${pendingEvents}`)
}
Expand Down
28 changes: 18 additions & 10 deletions core/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface GardenCliParams {
plugins?: GardenPluginReference[]
initLogger?: boolean
cloudApiFactory?: CloudApiFactory
globalConfigStoreDir?: string
}

function hasHelpFlag(argv: minimist.ParsedArgs) {
Expand All @@ -81,11 +82,18 @@ export class GardenCli {
private initLogger: boolean
public processRecord?: GardenProcess
protected cloudApiFactory: CloudApiFactory

constructor({ plugins, initLogger = false, cloudApiFactory = CloudApi.factory }: GardenCliParams = {}) {
private globalConfigStore: GlobalConfigStore

constructor({
plugins,
globalConfigStoreDir: globalConfigStorePath,
initLogger = false,
cloudApiFactory = CloudApi.factory,
}: GardenCliParams = {}) {
this.plugins = plugins || []
this.initLogger = initLogger
this.cloudApiFactory = cloudApiFactory
this.globalConfigStore = new GlobalConfigStore(globalConfigStorePath)

const commands = sortBy(getBuiltinCommands(), (c) => c.name)
commands.forEach((command) => this.addCommand(command))
Expand Down Expand Up @@ -222,9 +230,7 @@ ${renderCommands(commands)}
const commandLoggerType = command.getTerminalWriterType({ opts: parsedOpts, args: parsedArgs })
getRootLogger().setTerminalWriter(getTerminalWriterType({ silent, output, loggerTypeOpt, commandLoggerType }))

const globalConfigStore = new GlobalConfigStore()

await validateRuntimeRequirementsCached(log, globalConfigStore, checkRequirements)
await validateRuntimeRequirementsCached(log, this.globalConfigStore, checkRequirements)

command.printHeader({ log, args: parsedArgs, opts: parsedOpts })
const sessionId = uuidv4()
Expand All @@ -239,7 +245,7 @@ ${renderCommands(commands)}
const distroName = getCloudDistributionName(cloudDomain)

try {
cloudApi = await this.cloudApiFactory({ log, cloudDomain, globalConfigStore })
cloudApi = await this.cloudApiFactory({ log, cloudDomain, globalConfigStore: this.globalConfigStore })
} catch (err) {
if (err instanceof CloudApiTokenRefreshError) {
log.warn(dedent`
Expand Down Expand Up @@ -273,6 +279,7 @@ ${renderCommands(commands)}
forceRefresh,
variableOverrides: parsedCliVars,
plugins: this.plugins,
globalConfigStore: this.globalConfigStore,
cloudApi,
}

Expand Down Expand Up @@ -324,7 +331,7 @@ ${renderCommands(commands)}

if (processRecord) {
// Update the db record for the process
await globalConfigStore.update("activeProcesses", String(processRecord.pid), {
await this.globalConfigStore.update("activeProcesses", String(processRecord.pid), {
command: command.name,
sessionId,
persistent,
Expand All @@ -338,7 +345,9 @@ ${renderCommands(commands)}
}
}

analytics = await garden.getAnalyticsHandler()
if (command.enableAnalytics) {
analytics = await garden.getAnalyticsHandler()
}

// Register log file writers. We need to do this after the Garden class is initialised because
// the file writers depend on the project root.
Expand Down Expand Up @@ -571,8 +580,7 @@ ${renderCommands(commands)}
}

if (!processRecord) {
const globalConfigStore = new GlobalConfigStore()
processRecord = await registerProcess(globalConfigStore, command.getFullName(), args)
processRecord = await registerProcess(this.globalConfigStore, command.getFullName(), args)
}

this.processRecord = processRecord!
Expand Down
Loading