From 85e619116b39aa943b4b8a5da1eb4fbc665a67ce Mon Sep 17 00:00:00 2001 From: Charles Shin Date: Mon, 20 Nov 2023 09:34:05 -0800 Subject: [PATCH] feat: usage data consent (#685) * feat: user consent for telemetry * fix: removing unused vars * fix: lint errors * feat: integrate with usage_data_emitter * fix: update API.md * update comment * fix: refactor and move LocalConfigController to platform-core * fix: add encoding when reading config file * fix: adding additional file verification check * fix: update unit test * fix: export all types to update API.md * fix: update API.md * fix: open file using file descriptor * fix: update fs mocks in unit test * fix: update fs.open mock * fix: mock fs open for unit test --- .changeset/forty-geese-decide.md | 5 + .changeset/nine-socks-attack.md | 5 + .changeset/slimy-cups-draw.md | 6 + .eslint_dictionary.json | 1 + package-lock.json | 10 +- .../configure/configure_command_factory.ts | 6 + .../configure_telemetry_command.test.ts | 70 +++++++++ .../telemetry/configure_telemetry_command.ts | 63 ++++++++ .../sandbox/sandbox_command_factory.ts | 11 +- .../sandbox_event_handler_factory.test.ts | 2 +- .../sandbox/sandbox_event_handler_factory.ts | 8 +- .../src/amplify_project_creator.test.ts | 8 + .../src/amplify_project_creator.ts | 10 ++ packages/platform-core/API.md | 25 +++- .../local_configuration_controller.test.ts | 96 ++++++++++++ .../config/local_configuration_controller.ts | 137 ++++++++++++++++++ .../local_configuration_controller_factory.ts | 42 ++++++ packages/platform-core/src/index.ts | 2 + .../platform-core/src/usage-data/constants.ts | 5 + .../src/usage-data/noop_usage_data_emitter.ts | 22 +++ .../usage_data_emitter_factory.test.ts | 56 +++++++ .../usage-data/usage_data_emitter_factory.ts | 21 ++- 22 files changed, 594 insertions(+), 17 deletions(-) create mode 100644 .changeset/forty-geese-decide.md create mode 100644 .changeset/nine-socks-attack.md create mode 100644 .changeset/slimy-cups-draw.md create mode 100644 packages/cli/src/commands/configure/telemetry/configure_telemetry_command.test.ts create mode 100644 packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts create mode 100644 packages/platform-core/src/config/local_configuration_controller.test.ts create mode 100644 packages/platform-core/src/config/local_configuration_controller.ts create mode 100644 packages/platform-core/src/config/local_configuration_controller_factory.ts create mode 100644 packages/platform-core/src/usage-data/noop_usage_data_emitter.ts create mode 100644 packages/platform-core/src/usage-data/usage_data_emitter_factory.test.ts diff --git a/.changeset/forty-geese-decide.md b/.changeset/forty-geese-decide.md new file mode 100644 index 0000000000..a36ce1e730 --- /dev/null +++ b/.changeset/forty-geese-decide.md @@ -0,0 +1,5 @@ +--- +'create-amplify': patch +--- + +Adding message about usage data tracking when creating new project diff --git a/.changeset/nine-socks-attack.md b/.changeset/nine-socks-attack.md new file mode 100644 index 0000000000..3108561ed2 --- /dev/null +++ b/.changeset/nine-socks-attack.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/backend-cli': patch +--- + +Added subcommands under configure data tracking preferences diff --git a/.changeset/slimy-cups-draw.md b/.changeset/slimy-cups-draw.md new file mode 100644 index 0000000000..73dc35ea0e --- /dev/null +++ b/.changeset/slimy-cups-draw.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/platform-core': patch +'@aws-amplify/backend-cli': patch +--- + +integrate usage data tracking consent with usage-data-emitter diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index c1b2f35d00..8047d71a6d 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -62,6 +62,7 @@ "localhost", "lsof", "lstat", + "macos", "matchers", "mfas", "mkdtemp", diff --git a/package-lock.json b/package-lock.json index 6585e4f0f9..33326f02c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20014,11 +20014,11 @@ }, "packages/backend": { "name": "@aws-amplify/backend", - "version": "0.5.2", + "version": "0.5.3", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-auth": "^0.3.2", - "@aws-amplify/backend-data": "^0.7.0", + "@aws-amplify/backend-data": "^0.8.0", "@aws-amplify/backend-function": "^0.2.2", "@aws-amplify/backend-output-schemas": "^0.4.0", "@aws-amplify/backend-output-storage": "^0.2.3", @@ -20058,7 +20058,7 @@ }, "packages/backend-data": { "name": "@aws-amplify/backend-data", - "version": "0.7.1", + "version": "0.8.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.4.0", @@ -20378,7 +20378,7 @@ } }, "packages/create-amplify": { - "version": "0.3.5", + "version": "0.3.6", "license": "Apache-2.0", "dependencies": { "@aws-amplify/cli-core": "^0.2.0", @@ -20543,7 +20543,7 @@ "version": "0.3.5", "license": "Apache-2.0", "devDependencies": { - "@aws-amplify/backend": "0.5.2", + "@aws-amplify/backend": "0.5.3", "@aws-amplify/backend-auth": "0.3.3", "@aws-amplify/backend-secret": "^0.3.0", "@aws-amplify/backend-storage": "0.3.0", diff --git a/packages/cli/src/commands/configure/configure_command_factory.ts b/packages/cli/src/commands/configure/configure_command_factory.ts index c9385fd75a..62de1936ea 100644 --- a/packages/cli/src/commands/configure/configure_command_factory.ts +++ b/packages/cli/src/commands/configure/configure_command_factory.ts @@ -1,7 +1,9 @@ +import { configControllerFactory } from '@aws-amplify/platform-core'; import { CommandModule } from 'yargs'; import { ConfigureProfileCommand } from './configure_profile_command.js'; import { ProfileController } from './profile_controller.js'; import { ConfigureCommand } from './configure_command.js'; +import { ConfigureTelemetryCommand } from './telemetry/configure_telemetry_command.js'; /** * Creates a configure command. */ @@ -10,8 +12,12 @@ export const createConfigureCommand = (): CommandModule => { const configureProfileCommand = new ConfigureProfileCommand( profileController ); + const configureTelemetryCommand = new ConfigureTelemetryCommand( + configControllerFactory.getInstance('usage_data_preferences.json') + ); return new ConfigureCommand([ configureProfileCommand as unknown as CommandModule, + configureTelemetryCommand as unknown as CommandModule, ]); }; diff --git a/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.test.ts b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.test.ts new file mode 100644 index 0000000000..0947296ac7 --- /dev/null +++ b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.test.ts @@ -0,0 +1,70 @@ +import { Printer } from '@aws-amplify/cli-core'; +import { beforeEach, describe, it, mock } from 'node:test'; +import yargs, { CommandModule } from 'yargs'; +import assert from 'node:assert'; +import { TestCommandRunner } from '../../../test-utils/command_runner.js'; +import { ConfigureTelemetryCommand } from './configure_telemetry_command.js'; +import { + USAGE_DATA_TRACKING_ENABLED, + configControllerFactory, +} from '@aws-amplify/platform-core'; + +void describe('configure command', () => { + const mockedConfigControllerSet = mock.fn(); + mock.method(configControllerFactory, 'getInstance', () => ({ + set: mockedConfigControllerSet, + })); + const telemetryCommand = new ConfigureTelemetryCommand( + configControllerFactory.getInstance('usage_data_preferences.json') + ); + const parser = yargs().command(telemetryCommand as unknown as CommandModule); + const commandRunner = new TestCommandRunner(parser); + + const mockedPrint = mock.method(Printer, 'print'); + + beforeEach(() => { + mockedPrint.mock.resetCalls(); + mockedConfigControllerSet.mock.resetCalls(); + }); + + void it('enable telemetry & updates local config', async () => { + await commandRunner.runCommand(`telemetry enable`); + assert.match( + mockedPrint.mock.calls[0].arguments[0], + /You have enabled telemetry data collection/ + ); + assert.strictEqual( + mockedConfigControllerSet.mock.calls[0].arguments[0], + USAGE_DATA_TRACKING_ENABLED + ); + assert.strictEqual( + mockedConfigControllerSet.mock.calls[0].arguments[1], + true + ); + }); + + void it('disables telemetry & updates local config', async () => { + await commandRunner.runCommand(`telemetry disable`); + assert.match( + mockedPrint.mock.calls[0].arguments[0], + /You have disabled telemetry data collection/ + ); + assert.strictEqual( + mockedConfigControllerSet.mock.calls[0].arguments[0], + USAGE_DATA_TRACKING_ENABLED + ); + assert.strictEqual( + mockedConfigControllerSet.mock.calls[0].arguments[1], + false + ); + }); + + void it('if subcommand is not defined, it should list of subcommands and demandCommand', async () => { + await commandRunner.runCommand(`telemetry`); + assert.match( + mockedPrint.mock.calls[0].arguments[0], + /Not enough non-option arguments: got 0, need at least 1/ + ); + assert.strictEqual(mockedConfigControllerSet.mock.callCount(), 0); + }); +}); diff --git a/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts new file mode 100644 index 0000000000..2c35df7750 --- /dev/null +++ b/packages/cli/src/commands/configure/telemetry/configure_telemetry_command.ts @@ -0,0 +1,63 @@ +import { Printer } from '@aws-amplify/cli-core'; +import { + ConfigurationController, + USAGE_DATA_TRACKING_ENABLED, +} from '@aws-amplify/platform-core'; +import { Argv, CommandModule } from 'yargs'; +import { handleCommandFailure } from '../../../command_failure_handler.js'; +/** + * Command to configure AWS Amplify profile. + */ +export class ConfigureTelemetryCommand implements CommandModule { + /** + * @inheritDoc + */ + readonly command: string; + + /** + * @inheritDoc + */ + readonly describe: string; + + /** + * Configure profile command. + */ + constructor(private readonly configController: ConfigurationController) { + this.command = 'telemetry'; + this.describe = 'Configure anonymous usage data collection'; + } + + /** + * @inheritDoc + */ + handler = () => { + // CommandModule requires handler implementation. But this is never called if top level command + // is configured to require subcommand. + // Help is printed by default in that case before ever attempting to call handler. + throw new Error('Top level generate handler should never be called'); + }; + + /** + * @inheritDoc + */ + builder = (yargs: Argv) => { + return yargs + .command('enable', 'Enable anonymous data collection', {}, async () => { + await this.configController.set(USAGE_DATA_TRACKING_ENABLED, true); + + Printer.print('You have enabled telemetry data collection'); + }) + .command('disable', 'Disable anonymous data collection', {}, async () => { + await this.configController.set(USAGE_DATA_TRACKING_ENABLED, false); + + Printer.print('You have disabled telemetry data collection'); + }) + .demandCommand() + .strictCommands() + .recommendCommands() + .fail((msg, err) => { + handleCommandFailure(msg, err, yargs); + yargs.exit(1, err); + }); + }; +} diff --git a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts index 0bd492e46b..e1f5797ecf 100644 --- a/packages/cli/src/commands/sandbox/sandbox_command_factory.ts +++ b/packages/cli/src/commands/sandbox/sandbox_command_factory.ts @@ -50,13 +50,14 @@ export const createSandboxCommand = (): CommandModule< credentialProvider ); + const libraryVersion = + new PackageJsonReader().read( + fileURLToPath(new URL('../../../package.json', import.meta.url)) + ).version ?? ''; + const eventHandlerFactory = new SandboxEventHandlerFactory( sandboxBackendIdentifierResolver, - new UsageDataEmitterFactory().getInstance( - new PackageJsonReader().read( - fileURLToPath(new URL('../../../package.json', import.meta.url)) - ).version ?? '' - ) + async () => await new UsageDataEmitterFactory().getInstance(libraryVersion) ); const commandMiddleWare = new CommandMiddleware(); diff --git a/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.test.ts b/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.test.ts index 4f4300c251..9f4d6d0588 100644 --- a/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.test.ts +++ b/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.test.ts @@ -40,7 +40,7 @@ void describe('sandbox_event_handler_factory', () => { name: 'name', type: 'sandbox', }), - usageDataEmitterMock + async () => usageDataEmitterMock ); afterEach(() => { diff --git a/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.ts b/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.ts index 4adb9069d1..2f121a6e2a 100644 --- a/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.ts +++ b/packages/cli/src/commands/sandbox/sandbox_event_handler_factory.ts @@ -15,7 +15,7 @@ export class SandboxEventHandlerFactory { private readonly getBackendIdentifier: ( sandboxName?: string ) => Promise, - private readonly usageDataEmitter: UsageDataEmitter + private readonly getUsageDataEmitter: () => Promise ) {} getSandboxEventHandlers: SandboxEventHandlerCreator = ({ @@ -28,6 +28,7 @@ export class SandboxEventHandlerFactory { const backendIdentifier = await this.getBackendIdentifier( sandboxName ); + const usageDataEmitter = await this.getUsageDataEmitter(); try { await clientConfigLifecycleHandler.generateClientConfigFile( backendIdentifier @@ -35,7 +36,7 @@ export class SandboxEventHandlerFactory { if (args && args[0]) { const deployResult = args[0] as DeployResult; if (deployResult && deployResult.deploymentTimes) { - await this.usageDataEmitter.emitSuccess( + await usageDataEmitter.emitSuccess( deployResult.deploymentTimes, { command: 'Sandbox' } ); @@ -68,12 +69,13 @@ export class SandboxEventHandlerFactory { ], failedDeployment: [ async (...args: unknown[]) => { + const usageDataEmitter = await this.getUsageDataEmitter(); if (args.length == 0 || !args[0]) { return; } const deployError = args[0] as Error; if (deployError && deployError.message) { - await this.usageDataEmitter.emitFailure(deployError, { + await usageDataEmitter.emitFailure(deployError, { command: 'Sandbox', }); } diff --git a/packages/create-amplify/src/amplify_project_creator.test.ts b/packages/create-amplify/src/amplify_project_creator.test.ts index a617bc812d..64d23e6d6c 100644 --- a/packages/create-amplify/src/amplify_project_creator.test.ts +++ b/packages/create-amplify/src/amplify_project_creator.test.ts @@ -45,6 +45,10 @@ void describe('AmplifyProjectCreator', () => { logMock.log.mock.calls[4].arguments[0], 'Welcome to AWS Amplify! \nRun `npx amplify help` for a list of available commands. \nGet started by running `npx amplify sandbox`.' ); + assert.equal( + logMock.log.mock.calls[5].arguments[0], + `Amplify (Gen 2) collects anonymous telemetry data about general usage of the CLI.\n\nParticipation is optional, and you may opt-out by using \`amplify configure telemetry disable\`.\n\nTo learn more about telemetry, visit https://docs.amplify.aws/gen2/reference/telemetry` + ); }); void it('should instruct users to use the custom project root', async () => { @@ -76,5 +80,9 @@ void describe('AmplifyProjectCreator', () => { logMock.log.mock.calls[4].arguments[0], 'Welcome to AWS Amplify! \nRun `npx amplify help` for a list of available commands. \nGet started by running `cd ./project/root; npx amplify sandbox`.' ); + assert.equal( + logMock.log.mock.calls[5].arguments[0], + `Amplify (Gen 2) collects anonymous telemetry data about general usage of the CLI.\n\nParticipation is optional, and you may opt-out by using \`amplify configure telemetry disable\`.\n\nTo learn more about telemetry, visit https://docs.amplify.aws/gen2/reference/telemetry` + ); }); }); diff --git a/packages/create-amplify/src/amplify_project_creator.ts b/packages/create-amplify/src/amplify_project_creator.ts index 2a2d300a96..6f2e005ae2 100644 --- a/packages/create-amplify/src/amplify_project_creator.ts +++ b/packages/create-amplify/src/amplify_project_creator.ts @@ -5,6 +5,8 @@ import { NpmProjectInitializer } from './npm_project_initializer.js'; import { GitIgnoreInitializer } from './gitignore_initializer.js'; import { logger } from './logger.js'; +const LEARN_MORE_USAGE_DATA_TRACKING_LINK = `https://docs.amplify.aws/gen2/reference/telemetry`; + /** * */ @@ -72,5 +74,13 @@ export class AmplifyProjectCreator { Run \`npx amplify help\` for a list of available commands. Get started by running \`${cdCommand}npx amplify sandbox\`.` ); + + logger.log( + `Amplify (Gen 2) collects anonymous telemetry data about general usage of the CLI. + +Participation is optional, and you may opt-out by using \`amplify configure telemetry disable\`. + +To learn more about telemetry, visit ${LEARN_MORE_USAGE_DATA_TRACKING_LINK}` + ); }; } diff --git a/packages/platform-core/API.md b/packages/platform-core/API.md index 72c99e72c3..30228320e1 100644 --- a/packages/platform-core/API.md +++ b/packages/platform-core/API.md @@ -30,6 +30,23 @@ export enum CDKContextKey { DEPLOYMENT_TYPE = "amplify-backend-type" } +// @public (undocumented) +export const configControllerFactory: ConfigurationControllerFactory; + +// @public (undocumented) +export type ConfigurationController = { + get: (path: string) => Promise; + set: (path: string, value: string | boolean | number) => Promise; + clear: () => Promise; + write: () => Promise; +}; + +// @public +export class ConfigurationControllerFactory { + constructor(); + getInstance: (configFileName: LocalConfigurationFileName) => ConfigurationController; +} + // @public export class FilePathExtractor { constructor(stackTraceLine: string); @@ -37,6 +54,9 @@ export class FilePathExtractor { extract: () => string | undefined; } +// @public (undocumented) +export type LocalConfigurationFileName = 'usage_data_preferences.json'; + // @public (undocumented) export type PackageJson = z.infer; @@ -62,6 +82,9 @@ export const packageJsonSchema: z.ZodObject<{ type?: "module" | "commonjs" | undefined; }>; +// @public +export const USAGE_DATA_TRACKING_ENABLED = "telemetry.enabled"; + // @public (undocumented) export type UsageDataEmitter = { emitSuccess: (metrics?: Record, dimensions?: Record) => Promise; @@ -70,7 +93,7 @@ export type UsageDataEmitter = { // @public export class UsageDataEmitterFactory { - getInstance: (libraryVersion: string) => UsageDataEmitter; + getInstance: (libraryVersion: string) => Promise; } // (No @packageDocumentation comment for this package) diff --git a/packages/platform-core/src/config/local_configuration_controller.test.ts b/packages/platform-core/src/config/local_configuration_controller.test.ts new file mode 100644 index 0000000000..3397149971 --- /dev/null +++ b/packages/platform-core/src/config/local_configuration_controller.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs/promises'; +import { LocalConfigurationController } from './local_configuration_controller.js'; + +void describe('config controller', () => { + const mockedFsReadFile = mock.method(fs, 'readFile'); + const mockedFsWriteFile = mock.method(fs, 'writeFile'); + const mockedFsOpen = mock.method(fs, 'open'); + + beforeEach(() => { + mockedFsReadFile.mock.resetCalls(); + mockedFsWriteFile.mock.resetCalls(); + mockedFsOpen.mock.resetCalls(); + }); + + void it('if config has not been cached, read from fs', async () => { + mockedFsOpen.mock.mockImplementationOnce(() => { + return Promise.resolve(); + }); + mockedFsReadFile.mock.mockImplementationOnce(function () { + return Promise.resolve('{"hello": 123}'); + }); + const controller = new LocalConfigurationController(); + const resolvedValue = await controller.get('hello.world'); + assert.strictEqual(resolvedValue, undefined); + assert.strictEqual(mockedFsOpen.mock.callCount(), 1); + assert.strictEqual(mockedFsReadFile.mock.callCount(), 1); + }); + + void it('should not throw & return undefined with path points to undefined nested object ', async () => { + const controller = new LocalConfigurationController(); + controller._store = {}; + const resolvedValue = await controller.get('hello.world'); + assert.strictEqual(resolvedValue, undefined); + assert.equal(mockedFsReadFile.mock.callCount(), 0); + }); + + void it('should get value ', async () => { + const controller = new LocalConfigurationController(); + controller._store = { hello: false }; + const resolvedValue = await controller.get('hello'); + assert.strictEqual(resolvedValue, false); + assert.equal(mockedFsReadFile.mock.callCount(), 0); + }); + + void it('should get value by path ', async () => { + const controller = new LocalConfigurationController(); + controller._store = { hello: { world: 'foo' } }; + const resolvedValue = await controller.get('hello.world'); + assert.strictEqual(resolvedValue, 'foo'); + assert.equal(mockedFsReadFile.mock.callCount(), 0); + }); + + void it('set by path should override value', async () => { + const controller = new LocalConfigurationController(); + controller._store = { hello: { world: 'foo' } }; + await controller.set('hello.world', false); + assert.deepStrictEqual(controller._store, { + hello: { world: false }, + }); + assert.equal(mockedFsReadFile.mock.callCount(), 0); + }); + + void it('set by path should set value and not change existing values', async () => { + const controller = new LocalConfigurationController(); + controller._store = { hello: { world: { foo: 'bar' } } }; + await controller.set('hello.baz', 7); + assert.deepStrictEqual(controller._store, { + hello: { + world: { foo: 'bar' }, + baz: 7, + }, + }); + assert.equal(mockedFsReadFile.mock.callCount(), 0); + }); + + void it('if config has not been cached & config file does not exist, it should init cache, config file, then write to file', async () => { + mockedFsOpen.mock.mockImplementationOnce(() => { + throw Error('file does not exist'); + }); + + const controller = new LocalConfigurationController(); + await controller.set('hello.world', true); + + assert.strictEqual(mockedFsOpen.mock.callCount(), 1); + assert.strictEqual(mockedFsReadFile.mock.callCount(), 0); + assert.strictEqual(mockedFsWriteFile.mock.callCount(), 2); + assert.deepStrictEqual(controller._store, { hello: { world: true } }); + assert.deepStrictEqual(mockedFsWriteFile.mock.calls[0].arguments[1], '{}'); + assert.deepStrictEqual( + mockedFsWriteFile.mock.calls[1].arguments[1], + JSON.stringify({ hello: { world: true } }) + ); + }); +}); diff --git a/packages/platform-core/src/config/local_configuration_controller.ts b/packages/platform-core/src/config/local_configuration_controller.ts new file mode 100644 index 0000000000..4991819cc7 --- /dev/null +++ b/packages/platform-core/src/config/local_configuration_controller.ts @@ -0,0 +1,137 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { ConfigurationController } from './local_configuration_controller_factory'; + +/** + * Used to interact with config file based on OS. + */ +export class LocalConfigurationController implements ConfigurationController { + dirPath: string; + configFilePath: string; + _store: Record; + + /** + * Initializes paths to project config dir & config file. + */ + constructor( + private readonly projectName = 'amplify', + private readonly configFileName = 'config.json' + ) { + this.dirPath = this.getConfigDirPath(this.projectName); + this.configFilePath = path.join(this.dirPath, this.configFileName); + } + + /** + * Getter for cached config, retrieves config from disk if not cached already. + * If the store is not cached & config file does not exist, it will create a blank one. + */ + private async store(): Promise> { + if (this._store) { + return this._store; + } + try { + // check if file exists & readable. + const fd = await fs.open( + this.configFilePath, + fs.constants.F_OK, + fs.constants.O_RDWR + ); + + const fileContent = await fs.readFile(fd, 'utf-8'); + this._store = JSON.parse(fileContent); + } catch { + this._store = {}; + await this.write(); + } + return this._store; + } + + /** + * Creates project directory to store config if it doesn't exist yet. + */ + private mkConfigDir() { + return fs.mkdir(this.dirPath, { recursive: true }); + } + + /** + * Gets values from cached config by path. + */ + async get(path: string) { + return path + .split('.') + .reduce((acc: Record, current: string) => { + return acc?.[current] as Record; + }, await this.store()) as T; + } + + /** + * Set value by path & update config file to disk. + */ + async set(path: string, value: string | boolean | number) { + let current: Record = await this.store(); + + path.split('.').forEach((key, index, keys) => { + if (index === keys.length - 1) { + current[key] = value; + } else { + if (current[key] == null) { + current[key] = {}; + } + current = current[key] as Record; + } + }); + + await this.write(); + } + + /** + * Writes config file to disk if found. + */ + async write() { + // creates project directory if it doesn't exist. + await this.mkConfigDir(); + + const output = JSON.stringify(this._store ? this._store : {}); + await fs.writeFile(this.configFilePath, output, 'utf8'); + } + + /** + * Reset cached config and delete the config file. + */ + async clear() { + this._store = {}; + await fs.rm(this.configFilePath); + } + + /** + * Returns the path to config directory depending on OS + */ + private getConfigDirPath(name: string): string { + const homedir = os.homedir(); + + const macos = () => path.join(homedir, 'Library', 'Preferences', name); + const windows = () => { + return path.join( + process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), + name, + 'Config' + ); + }; + const linux = () => { + return path.join( + process.env.XDG_STATE_HOME || path.join(homedir, '.local', 'state'), + name + ); + }; + + switch (process.platform) { + case 'darwin': + return macos(); + case 'win32': + return windows(); + default: + return linux(); + } + } +} diff --git a/packages/platform-core/src/config/local_configuration_controller_factory.ts b/packages/platform-core/src/config/local_configuration_controller_factory.ts new file mode 100644 index 0000000000..9bc88ae11d --- /dev/null +++ b/packages/platform-core/src/config/local_configuration_controller_factory.ts @@ -0,0 +1,42 @@ +import { LocalConfigurationController } from '../config/local_configuration_controller.js'; + +export type ConfigurationController = { + get: (path: string) => Promise; + set: (path: string, value: string | boolean | number) => Promise; + clear: () => Promise; + write: () => Promise; +}; + +export type LocalConfigurationFileName = 'usage_data_preferences.json'; + +/** + * Instantiates LocalConfigurationController + */ +export class ConfigurationControllerFactory { + private controllers: Record; + + /** + * initialized empty map of ConfigurationController; + */ + constructor() { + this.controllers = {}; + } + /** + * Returns a LocalConfigurationController + */ + getInstance = ( + configFileName: LocalConfigurationFileName + ): ConfigurationController => { + if (this.controllers[configFileName]) { + return this.controllers[configFileName]; + } + + this.controllers[configFileName] = new LocalConfigurationController( + 'amplify', + configFileName + ); + return this.controllers[configFileName]; + }; +} + +export const configControllerFactory = new ConfigurationControllerFactory(); diff --git a/packages/platform-core/src/index.ts b/packages/platform-core/src/index.ts index 27df5115b1..d1980d40fc 100644 --- a/packages/platform-core/src/index.ts +++ b/packages/platform-core/src/index.ts @@ -3,4 +3,6 @@ export * from './backend_entry_point_locator.js'; export * from './extract_file_path_from_stack_trace_line.js'; export * from './package_json_reader.js'; export * from './usage-data/usage_data_emitter_factory.js'; +export * from './config/local_configuration_controller_factory.js'; +export { USAGE_DATA_TRACKING_ENABLED } from './usage-data/constants.js'; export { CDKContextKey } from './cdk_context_key.js'; diff --git a/packages/platform-core/src/usage-data/constants.ts b/packages/platform-core/src/usage-data/constants.ts index bb386e9ea4..2abd639ce5 100644 --- a/packages/platform-core/src/usage-data/constants.ts +++ b/packages/platform-core/src/usage-data/constants.ts @@ -7,3 +7,8 @@ export const latestApiVersion = 'v1.0'; * returns the latest available payload version */ export const latestPayloadVersion = '1.1.0'; + +/** + * Key to access whether user opted-in status of usage data tracking preference. + */ +export const USAGE_DATA_TRACKING_ENABLED = 'telemetry.enabled'; diff --git a/packages/platform-core/src/usage-data/noop_usage_data_emitter.ts b/packages/platform-core/src/usage-data/noop_usage_data_emitter.ts new file mode 100644 index 0000000000..6165e3ddf6 --- /dev/null +++ b/packages/platform-core/src/usage-data/noop_usage_data_emitter.ts @@ -0,0 +1,22 @@ +import { UsageDataEmitter } from './usage_data_emitter_factory'; + +/** + * no-op class that implements UsageDataEmitter. + */ +export class NoOpUsageDataEmitter implements UsageDataEmitter { + /** + * no-op emitSuccess + */ + emitSuccess(): Promise { + // no-op + return Promise.resolve(); + } + + /** + * no-op emitFailure + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + emitFailure(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/platform-core/src/usage-data/usage_data_emitter_factory.test.ts b/packages/platform-core/src/usage-data/usage_data_emitter_factory.test.ts new file mode 100644 index 0000000000..cdd22dee2d --- /dev/null +++ b/packages/platform-core/src/usage-data/usage_data_emitter_factory.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert'; +import { beforeEach, describe, it, mock } from 'node:test'; + +import { UsageDataEmitterFactory } from './usage_data_emitter_factory'; +import { DefaultUsageDataEmitter } from './usage_data_emitter'; +import { NoOpUsageDataEmitter } from './noop_usage_data_emitter'; +import { + ConfigurationController, + configControllerFactory, +} from '../config/local_configuration_controller_factory'; + +void describe('UsageDataEmitterFactory', () => { + const configControllerGet = mock.fn(); + const mockedConfigController: ConfigurationController = { + get: configControllerGet, + } as unknown as ConfigurationController; + + mock.method( + configControllerFactory, + 'getInstance', + () => mockedConfigController + ); + + beforeEach(() => { + configControllerGet.mock.resetCalls(); + }); + + void it('returns DefaultUsageDataEmitter by default', async () => { + configControllerGet.mock.mockImplementationOnce(() => undefined); + const dataEmitter = await new UsageDataEmitterFactory().getInstance( + '0.0.0' + ); + assert.strictEqual(configControllerGet.mock.callCount(), 1); + assert.strictEqual(dataEmitter instanceof DefaultUsageDataEmitter, true); + }); + + void it('returns NoOpUsageDataEmitter if AMPLIFY_DISABLE_TELEMETRY env var is set', async () => { + configControllerGet.mock.mockImplementationOnce(() => undefined); + process.env['AMPLIFY_DISABLE_TELEMETRY'] = '1'; + const dataEmitter = await new UsageDataEmitterFactory().getInstance( + '0.0.0' + ); + assert.strictEqual(dataEmitter instanceof NoOpUsageDataEmitter, true); + assert.strictEqual(configControllerGet.mock.callCount(), 1); + delete process.env['AMPLIFY_DISABLE_TELEMETRY']; + }); + + void it('returns NoOpUsageDataEmitter if local config file exists and reads true', async () => { + configControllerGet.mock.mockImplementationOnce(() => false); + const dataEmitter = await new UsageDataEmitterFactory().getInstance( + '0.0.0' + ); + assert.strictEqual(configControllerGet.mock.callCount(), 1); + assert.strictEqual(dataEmitter instanceof NoOpUsageDataEmitter, true); + }); +}); diff --git a/packages/platform-core/src/usage-data/usage_data_emitter_factory.ts b/packages/platform-core/src/usage-data/usage_data_emitter_factory.ts index 577160b345..55dec214e8 100644 --- a/packages/platform-core/src/usage-data/usage_data_emitter_factory.ts +++ b/packages/platform-core/src/usage-data/usage_data_emitter_factory.ts @@ -1,4 +1,7 @@ +import { configControllerFactory } from '../config/local_configuration_controller_factory.js'; +import { NoOpUsageDataEmitter } from './noop_usage_data_emitter.js'; import { DefaultUsageDataEmitter } from './usage_data_emitter.js'; +import { USAGE_DATA_TRACKING_ENABLED } from './constants.js'; export type UsageDataEmitter = { emitSuccess: ( @@ -16,9 +19,23 @@ export type UsageDataEmitter = { */ export class UsageDataEmitterFactory { /** - * Returns a ClientConfigGenerator for the given BackendIdentifier type + * Creates UsageDataEmitter for a given library version, usage data tracking preferences */ - getInstance = (libraryVersion: string): UsageDataEmitter => { + getInstance = async (libraryVersion: string): Promise => { + const configController = await configControllerFactory.getInstance( + 'usage_data_preferences.json' + ); + + const usageDataTrackingDisabledLocalFile = + (await configController.get(USAGE_DATA_TRACKING_ENABLED)) === + false; + + if ( + process.env.AMPLIFY_DISABLE_TELEMETRY || + usageDataTrackingDisabledLocalFile + ) { + return new NoOpUsageDataEmitter(); + } return new DefaultUsageDataEmitter(libraryVersion); }; }