diff --git a/core/cli/bin/run b/core/cli/bin/run index 7423de002..f9929bfc2 100755 --- a/core/cli/bin/run +++ b/core/cli/bin/run @@ -10,13 +10,17 @@ const argv = require('minimist')(process.argv.slice(2), { }) const { rootLogger } = require('@dotcom-tool-kit/logger') +const { TelemetryProcess } = require('@dotcom-tool-kit/telemetry') const { formatError } = require('../lib/messages') async function main() { + const metricsProcess = new TelemetryProcess(rootLogger) + const environment = process.env.CI ? 'ci' : 'local' + const metrics = metricsProcess.root({ environment }) try { if (argv.install) { const installHooks = require('../lib/install').default - await installHooks(rootLogger) + await installHooks(rootLogger, metrics) } else if (argv.listPlugins) { const { listPlugins } = require('../lib') await listPlugins(rootLogger) @@ -25,7 +29,7 @@ async function main() { await printConfig(rootLogger) } else if (argv.printMergedOptions) { const { printMergedOptions } = require('../lib') - await printMergedOptions(rootLogger) + await printMergedOptions(rootLogger, metrics) } else if (argv.help || argv._.length === 0) { const showHelp = require('../lib/help').default await showHelp(rootLogger, argv._) @@ -39,14 +43,16 @@ async function main() { // the command becomes something like `dotcom-tool-kit test:staged -- // index.js`. When this command is executed it runs the configured task // where the file path arguments would then be extracted. - await runCommands(rootLogger, argv._, argv['--']) + await runCommands(rootLogger, metrics, argv._, argv['--']) } else { - await runCommands(rootLogger, argv._) + await runCommands(rootLogger, metrics, argv._) } } } catch (error) { rootLogger.error(formatError(error), { skipFormat: true }) process.exitCode = error.exitCode || 1 + } finally { + metricsProcess.disconnect() } } diff --git a/core/cli/package.json b/core/cli/package.json index 6b54f30fb..811a225d6 100644 --- a/core/cli/package.json +++ b/core/cli/package.json @@ -38,6 +38,7 @@ "@dotcom-tool-kit/plugin": "^2.0.0", "@dotcom-tool-kit/schemas": "^1.9.0", "@dotcom-tool-kit/state": "^5.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@dotcom-tool-kit/validated": "^2.0.0", "endent": "^2.1.0", "lodash": "^4.17.21", diff --git a/core/cli/src/index.ts b/core/cli/src/index.ts index e05ab71ed..7f36513c8 100644 --- a/core/cli/src/index.ts +++ b/core/cli/src/index.ts @@ -3,6 +3,9 @@ import type { Logger } from 'winston' import util from 'util' import { formatPluginTree } from './messages' import { loadHookInstallations } from './install' +import { enableTelemetry } from './telemetry' +import type { RootOptions } from '@dotcom-tool-kit/plugin/src/root-schema' +import { TelemetryRecorder } from '@dotcom-tool-kit/telemetry' export { runCommands } from './tasks' export { shouldDisableNativeFetch } from './fetch' @@ -22,9 +25,10 @@ export async function printConfig(logger: Logger): Promise { logger.info(util.inspect(config, { depth: null, colors: true })) } -export async function printMergedOptions(logger: Logger): Promise { +export async function printMergedOptions(logger: Logger, metrics: TelemetryRecorder): Promise { const config = await loadConfig(logger, { validate: true, root: process.cwd() }) - const hookInstallations = (await loadHookInstallations(logger, config)).unwrap('invalid hooks') + enableTelemetry(metrics, config.pluginOptions['app root'].options as RootOptions) + const hookInstallations = (await loadHookInstallations(logger, metrics, config)).unwrap('invalid hooks') const mergedOptions = { hooks: hookInstallations.map((h) => h.options), diff --git a/core/cli/src/install.ts b/core/cli/src/install.ts index 537001c57..cce7d4b18 100644 --- a/core/cli/src/install.ts +++ b/core/cli/src/install.ts @@ -7,12 +7,16 @@ import { loadConfig } from './config' import { hasConfigChanged, updateHashes } from './config/hash' import type { ValidConfig } from '@dotcom-tool-kit/config' import { Hook, HookClass } from '@dotcom-tool-kit/base' +import type { RootOptions } from '@dotcom-tool-kit/plugin/src/root-schema' import { Validated, invalid, reduceValidated, valid } from '@dotcom-tool-kit/validated' import { HookModule, reducePluginHookInstallations } from './plugin/reduce-installations' import { findConflicts, withoutConflicts } from '@dotcom-tool-kit/conflict' import { formatUninstalledHooks } from './messages' import { importEntryPoint } from './plugin/entry-point' import { runInit } from './init' +import { guessSystemCode } from './systemCode' +import { enableTelemetry } from './telemetry' +import { TelemetryRecorder } from '@dotcom-tool-kit/telemetry' // implementation of the Array#every method that supports asynchronous predicates async function asyncEvery(arr: T[], pred: (x: T) => Promise): Promise { @@ -51,6 +55,7 @@ export const loadHookEntrypoints = async ( export const loadHookInstallations = async ( logger: Logger, + metrics: TelemetryRecorder, config: ValidConfig ): Promise> => { const hookClassResults = await loadHookEntrypoints(logger, config) @@ -83,17 +88,27 @@ export const loadHookInstallations = async ( return installationsWithoutConflicts.map((installations) => { return installations.map(({ hookConstructor, forHook, options }) => { const hookPlugin = config.hooks[forHook].plugin - return new hookConstructor(logger, forHook, options, config.pluginOptions[hookPlugin.id]?.options) + return new hookConstructor( + logger, + forHook, + options, + config.pluginOptions[hookPlugin.id]?.options, + metrics + ) }) }) } -export async function checkInstall(logger: Logger, config: ValidConfig): Promise { +export async function checkInstall( + logger: Logger, + metrics: TelemetryRecorder, + config: ValidConfig +): Promise { if (!(await hasConfigChanged(logger, config))) { return } - const hooks = (await loadHookInstallations(logger, config)).unwrap( + const hooks = (await loadHookInstallations(logger, metrics, config)).unwrap( 'hooks were found to be invalid when checking install' ) @@ -110,13 +125,20 @@ export async function checkInstall(logger: Logger, config: ValidConfig): Promise await updateHashes(config) } -export default async function installHooks(logger: Logger): Promise { +export default async function installHooks(logger: Logger, metrics: TelemetryRecorder): Promise { const config = await loadConfig(logger, { root: process.cwd() }) + enableTelemetry(metrics, config.pluginOptions['app root'].options as RootOptions) await runInit(logger, config) + const systemCode = await guessSystemCode(config) + let scoped = metrics + if (systemCode) { + scoped = metrics.scoped({ systemCode }) + } + const errors: Error[] = [] - const hooks = (await loadHookInstallations(logger, config)).unwrap( + const hooks = (await loadHookInstallations(logger, scoped, config)).unwrap( 'hooks were found to be invalid when installing' ) diff --git a/core/cli/src/systemCode.ts b/core/cli/src/systemCode.ts new file mode 100644 index 000000000..3ebfd5976 --- /dev/null +++ b/core/cli/src/systemCode.ts @@ -0,0 +1,28 @@ +import { readFile } from 'node:fs/promises' + +import type { ValidConfig } from '@dotcom-tool-kit/config' +import type { RootOptions } from '@dotcom-tool-kit/plugin/src/root-schema' +import type DopplerSchema from '@dotcom-tool-kit/doppler/src/schema' + +import type * as z from 'zod' + +export async function guessSystemCode(config: ValidConfig): Promise { + const systemCodeFromRoot = (config.pluginOptions['app root']?.options as RootOptions).systemCode + if (systemCodeFromRoot) { + return systemCodeFromRoot + } + + const systemCodeFromDoppler = ( + config.pluginOptions['@dotcom-tool-kit/doppler']?.options as z.infer + ).project + if (systemCodeFromDoppler && !systemCodeFromDoppler.startsWith('repo_')) { + return systemCodeFromDoppler + } + + try { + const packageJson = JSON.parse(await readFile('package.json', 'utf8')) + if (packageJson.name) { + return `npm:${packageJson.name}` + } + } catch {} +} diff --git a/core/cli/src/tasks.ts b/core/cli/src/tasks.ts index ab0d86732..876720565 100644 --- a/core/cli/src/tasks.ts +++ b/core/cli/src/tasks.ts @@ -10,11 +10,14 @@ import { styles } from '@dotcom-tool-kit/logger' import { shouldDisableNativeFetch } from './fetch' import { runInit } from './init' import { formatInvalidOption } from './messages' +import { guessSystemCode } from './systemCode' +import { enableTelemetry } from './telemetry' import { type TaskOptions, TaskSchemas } from '@dotcom-tool-kit/schemas' import { OptionsForTask } from '@dotcom-tool-kit/plugin' import type { RootOptions } from '@dotcom-tool-kit/plugin/src/root-schema' import pluralize from 'pluralize' import type { ReadonlyDeep } from 'type-fest' +import { type TelemetryRecorder, MockTelemetryClient } from '@dotcom-tool-kit/telemetry' export type ErrorSummary = { task: string @@ -24,7 +27,11 @@ export type ErrorSummary = { export async function loadTasks( logger: Logger, tasks: OptionsForTask[], - config: ReadonlyDeep + config: ReadonlyDeep, + // TODO:IM:20251215 make this a required parameter in the next major version. + // this function is currently used by the parallel plugin and so + // can't be changed yet. + metrics?: TelemetryRecorder ): Promise> { const taskResults = await Promise.all( tasks.map(async ({ task: taskId, options, plugin }) => { @@ -42,12 +49,14 @@ export async function loadTasks( } if (parsedOptions.success) { + const scoped = metrics?.scoped({ plugin: entryPoint.plugin.id }) const task = new (taskModule.baseClass as unknown as TaskConstructor)( logger, taskId, config.pluginOptions[entryPoint.plugin.id]?.options ?? {}, parsedOptions.data, - plugin + plugin, + scoped ) return valid(task) } else { @@ -72,6 +81,7 @@ export function handleTaskErrors(errors: ErrorSummary[], command: string) { export async function runTasks( logger: Logger, + metrics: TelemetryRecorder, config: ValidConfig, tasks: Task[], command: string, @@ -84,9 +94,11 @@ export async function runTasks( } for (const task of tasks) { + const scoped = metrics.scoped({ task: task.id }) try { logger.info(styles.taskHeader(`running ${styles.task(task.id)} task`)) await task.run({ files, command, cwd: config.root, config }) + scoped.recordEvent('tasks.completed', { success: true }) } catch (error) { // if there's an exit code, that's a request from the task to exit early if (error instanceof ToolKitError && error.exitCode) { @@ -99,6 +111,7 @@ export async function runTasks( task: task.id, error: error as Error }) + scoped.recordEvent('tasks.completed', { success: false }) } } @@ -111,10 +124,14 @@ export async function runCommandsFromConfig( logger: Logger, config: ValidConfig, commands: string[], - files?: string[] + files?: string[], + // TODO:IM:20251215 make this a required parameter in the next major version. + // this function is currently used by the monorepo plugin and so can't be + // changed yet. + metrics: TelemetryRecorder = new MockTelemetryClient() ): Promise { await runInit(logger, config) - await checkInstall(logger, config) + await checkInstall(logger, metrics, config) if ( shouldDisableNativeFetch(config.pluginOptions['app root'].options as RootOptions) && @@ -127,7 +144,8 @@ export async function runCommandsFromConfig( await Promise.all( commands.map(async (command) => { const tasks = config.commandTasks[command]?.tasks ?? [] - const validatedTaskInstances = await loadTasks(logger, tasks, config) + const scoped = metrics.scoped({ command }) + const validatedTaskInstances = await loadTasks(logger, tasks, config, scoped) return validatedTaskInstances.map((taskInstances) => ({ command, tasks: taskInstances })) }) @@ -139,12 +157,25 @@ export async function runCommandsFromConfig( Object.freeze(config) for (const { command, tasks } of commandTasks) { - await runTasks(logger, config, tasks, command, files) + const scoped = metrics.scoped({ command }) + await runTasks(logger, scoped, config, tasks, command, files) } } -export async function runCommands(logger: Logger, commands: string[], files?: string[]): Promise { +export async function runCommands( + logger: Logger, + metrics: TelemetryRecorder, + commands: string[], + files?: string[] +): Promise { const config = await loadConfig(logger, { root: process.cwd() }) + enableTelemetry(metrics, config.pluginOptions['app root'].options as RootOptions) + + const systemCode = await guessSystemCode(config) + let scoped = metrics + if (systemCode) { + scoped = metrics.scoped({ systemCode }) + } - return runCommandsFromConfig(logger, config, commands, files) + return runCommandsFromConfig(logger, config, commands, files, scoped) } diff --git a/core/cli/src/telemetry.ts b/core/cli/src/telemetry.ts new file mode 100644 index 000000000..f7054b3cd --- /dev/null +++ b/core/cli/src/telemetry.ts @@ -0,0 +1,11 @@ +import type { RootOptions } from '@dotcom-tool-kit/plugin/src/root-schema' +import type { TelemetryProcess, TelemetryRecorder } from '@dotcom-tool-kit/telemetry' + +// TODO:IM:20260113 in the future this type signature could be changed to allow +// enabling telemetry even without a valid config (and the root options derived +// from it,) so that we can track metrics related to invalid configurations. +export function enableTelemetry(metrics: TelemetryProcess | TelemetryRecorder, options: RootOptions) { + if (options.enableTelemetry) { + metrics.enable() + } +} diff --git a/core/cli/test/__snapshots__/config.test.ts.snap b/core/cli/test/__snapshots__/config.test.ts.snap index 0105e54b5..673386107 100644 --- a/core/cli/test/__snapshots__/config.test.ts.snap +++ b/core/cli/test/__snapshots__/config.test.ts.snap @@ -1702,6 +1702,7 @@ exports[`loadConfig should load a config from a root 1`] = ` }, "options": { "allowNativeFetch": false, + "enableTelemetry": false, }, "plugin": { "children": [ @@ -4885,6 +4886,7 @@ exports[`loadConfig should load a config from a root and validate it 1`] = ` }, "options": { "allowNativeFetch": false, + "enableTelemetry": false, }, "plugin": { "children": [ @@ -16502,6 +16504,7 @@ and pipeline.event.action != "closed")) }, "options": { "allowNativeFetch": false, + "enableTelemetry": false, }, "plugin": { "children": [ diff --git a/core/cli/test/index.test.ts b/core/cli/test/index.test.ts index 835407e7a..3f0422afd 100644 --- a/core/cli/test/index.test.ts +++ b/core/cli/test/index.test.ts @@ -2,6 +2,7 @@ import { ToolKitError } from '@dotcom-tool-kit/error' import type { Valid } from '@dotcom-tool-kit/validated' import type { Plugin } from '@dotcom-tool-kit/plugin' import type { ValidPluginsConfig } from '@dotcom-tool-kit/config' +import { MockTelemetryClient } from '@dotcom-tool-kit/telemetry' import { describe, expect, it, jest } from '@jest/globals' import * as path from 'path' import winston, { Logger } from 'winston' @@ -137,7 +138,8 @@ describe('cli', () => { resolvePlugin((plugin as Valid).value, validPluginConfig, logger) const validConfig = validateConfig(validPluginConfig) - const hooks = await loadHookInstallations(logger, validConfig) + const metrics = new MockTelemetryClient() + const hooks = await loadHookInstallations(logger, metrics, validConfig) expect(hooks.valid).toBe(true) }) }) diff --git a/core/cli/tsconfig.json b/core/cli/tsconfig.json index bab8c45e9..a36a00623 100644 --- a/core/cli/tsconfig.json +++ b/core/cli/tsconfig.json @@ -22,6 +22,9 @@ { "path": "../../lib/state" }, + { + "path": "../../lib/telemetry" + }, { "path": "../../lib/base" } diff --git a/lib/base/package.json b/lib/base/package.json index 4c9e85f55..9f1b2f9d2 100644 --- a/lib/base/package.json +++ b/lib/base/package.json @@ -12,6 +12,7 @@ "dependencies": { "@dotcom-tool-kit/conflict": "^2.0.0", "@dotcom-tool-kit/logger": "^5.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@dotcom-tool-kit/validated": "^2.0.0", "semver": "^7.7.3", "winston": "^3.17.0" diff --git a/lib/base/src/hook.ts b/lib/base/src/hook.ts index f8bf0d3a1..764398040 100644 --- a/lib/base/src/hook.ts +++ b/lib/base/src/hook.ts @@ -4,6 +4,7 @@ import { hookSymbol, typeSymbol } from './symbols' import type { z } from 'zod' import type { Plugin } from '@dotcom-tool-kit/plugin' import { Conflict, isConflict } from '@dotcom-tool-kit/conflict' +import { MockTelemetryClient, type TelemetryRecorder } from '@dotcom-tool-kit/telemetry' import type { Default } from './type-utils' export interface HookInstallation> { @@ -21,6 +22,7 @@ export abstract class Hook< State = unknown > extends Base { logger: Logger + metrics: TelemetryRecorder // This field is used to collect hooks that share state when running their // install methods. All hooks in the same group will run their install method // one after the other, and then their commitInstall method will be run with @@ -65,10 +67,13 @@ export abstract class Hook< logger: Logger, public id: string, public options: z.output>>>, - public pluginOptions: z.output>>> + public pluginOptions: z.output>>>, + // TODO:IM:20251215 make this a required parameter in the next major version + metrics: TelemetryRecorder = new MockTelemetryClient() ) { super() this.logger = logger.child({ hook: this.constructor.name }) + this.metrics = metrics.scoped({ hook: this.constructor.name }) } abstract isInstalled(): Promise @@ -83,7 +88,8 @@ export type HookConstructor = { logger: Logger, id: string, options: z.infer, - pluginOptions: z.infer + pluginOptions: z.infer, + metrics?: TelemetryRecorder ): Hook } diff --git a/lib/base/src/task.ts b/lib/base/src/task.ts index 23f5884cd..84dd7c4a6 100644 --- a/lib/base/src/task.ts +++ b/lib/base/src/task.ts @@ -3,6 +3,7 @@ import { Base } from './base' import { taskSymbol, typeSymbol } from './symbols' import type { Logger } from 'winston' import type { ValidConfig } from '@dotcom-tool-kit/config' +import { MockTelemetryClient, type TelemetryRecorder } from '@dotcom-tool-kit/telemetry' import { Plugin } from '@dotcom-tool-kit/plugin' import type { Default } from './type-utils' import type { ReadonlyDeep } from 'type-fest' @@ -29,16 +30,20 @@ export abstract class Task< } logger: Logger + metrics: TelemetryRecorder constructor( logger: Logger, public id: string, public pluginOptions: z.output>>>, public options: z.output>>>, - public plugin: Plugin + public plugin: Plugin, + // TODO:IM:20251215 make this a required parameter in the next major version + metrics: TelemetryRecorder = new MockTelemetryClient() ) { super() this.logger = logger.child({ task: id }) + this.metrics = metrics.scoped({ task: id }) } abstract run(runContext: TaskRunContext): Promise @@ -54,7 +59,8 @@ export type TaskConstructor = { id: string, pluginOptions: Partial>, options: Partial>, - plugin: Plugin + plugin: Plugin, + metrics?: TelemetryRecorder ): Task } diff --git a/lib/base/tsconfig.json b/lib/base/tsconfig.json index 5a73660b8..de8455495 100644 --- a/lib/base/tsconfig.json +++ b/lib/base/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../config" + }, + { + "path": "../telemetry" } ], "compilerOptions": { diff --git a/lib/plugin/src/root-schema.ts b/lib/plugin/src/root-schema.ts index cba8868de..ce3ea12eb 100644 --- a/lib/plugin/src/root-schema.ts +++ b/lib/plugin/src/root-schema.ts @@ -1,6 +1,17 @@ import * as z from 'zod' export const RootSchema = z.object({ - allowNativeFetch: z.boolean().default(false) + allowNativeFetch: z.boolean().default(false), + enableTelemetry: z + .boolean() + .default(false) + .describe( + 'Opt-in to send telemetry on your Tool Kit usage so we can track user patterns and friction points. This will be opt-out in a future version once the telemetry API has stabilised.' + ), + // TODO:IM:20251112 require this option in a future major version + systemCode: z + .string() + .optional() + .describe('Biz Ops system code or the package name prefixed with "npm:" otherwise') }) export type RootOptions = z.infer diff --git a/lib/telemetry/jest.config.js b/lib/telemetry/jest.config.js new file mode 100644 index 000000000..2ea38bb31 --- /dev/null +++ b/lib/telemetry/jest.config.js @@ -0,0 +1,5 @@ +const base = require('../../jest.config.base') + +module.exports = { + ...base.config +} diff --git a/lib/telemetry/package.json b/lib/telemetry/package.json new file mode 100644 index 000000000..a8a7976c8 --- /dev/null +++ b/lib/telemetry/package.json @@ -0,0 +1,34 @@ +{ + "name": "@dotcom-tool-kit/telemetry", + "version": "1.0.0", + "description": "", + "main": "lib", + "scripts": { + "test": "cd ../../ ; npx jest --silent --projects lib/telemetry" + }, + "keywords": [], + "author": "FT.com Platforms Team ", + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/financial-times/dotcom-tool-kit.git", + "directory": "lib/telemetry" + }, + "bugs": "https://github.com/financial-times/dotcom-tool-kit/issues", + "homepage": "https://github.com/financial-times/dotcom-tool-kit/tree/main/lib/telemetry", + "files": [ + "/lib" + ], + "volta": { + "extends": "../../package.json" + }, + "engines": { + "node": ">=20.x" + }, + "dependencies": { + "@dotcom-tool-kit/logger": "^5.0.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0" + } +} diff --git a/lib/telemetry/src/child.mts b/lib/telemetry/src/child.mts new file mode 100644 index 000000000..1db46947b --- /dev/null +++ b/lib/telemetry/src/child.mts @@ -0,0 +1,46 @@ +import { randomUUID } from 'node:crypto' + +import type { TelemetryEvent } from './types.ts' + +const endpoint = process.env.TOOL_KIT_TELEMETRY_ENDPOINT || 'https://client-metrics.ft.com/api/v1/ingest' + +const sessionId = randomUUID() + +let currentlyPublishing = false +let messageQueue: TelemetryEvent[] = [] + +process.on('message', (message: TelemetryEvent) => { + messageQueue.push(message) + tryToPublish() +}) + +const tryToPublish = async () => { + // will publish all the events stored in the messageQueue buffer if there + // isn't already a publish in progress (i.e., we aren't waiting for a + // response from the endpoint.) + if (!currentlyPublishing) { + // as long as there are no points that would cause us to yield to the event + // loop (e.g., an await expression,) we can treat this as a critical + // section and it's safe to read then write to currentlyPublishing. + currentlyPublishing = true + while (messageQueue.length > 0) { + const messages = JSON.stringify(messageQueue) + messageQueue = [] + await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // use the same request ID throughout the Tool Kit session so that we + // can correlate metrics from within the same invocation + 'X-Request-Id': sessionId + }, + body: messages + }) + } + /* eslint-disable-next-line require-atomic-updates -- + * This is safe because this code can only be run if currentlyPublishing + * was previously false for the batch publisher. + **/ + currentlyPublishing = false + } +} diff --git a/lib/telemetry/src/index.ts b/lib/telemetry/src/index.ts new file mode 100644 index 000000000..ed3586d23 --- /dev/null +++ b/lib/telemetry/src/index.ts @@ -0,0 +1,183 @@ +import { type ChildProcess, fork } from 'node:child_process' + +import type { Logger } from 'winston' + +import { createWritableLogger } from '@dotcom-tool-kit/logger' + +import type { TelemetryEvent, TelemetryAttributes, Namespace, NamespaceSchemas } from './types' + +/** + * A TelemetryProcess will spawn a child process that will publish recording + * telemetry event asynchronously from the main Tool Kit process. Note that you + * will need a TelemetryRecorder to actually record telemetry events, which is + * typically created with this class's `.root()` method. + */ +export class TelemetryProcess { + /** Will be set when `this.startProcess()` is called. */ + private child: ChildProcess | undefined + /** Stores recorded metrics until the process has been enabled so that we can + * retroactively log those metrics once we know telemetry has been allowed. */ + eventBuffer: TelemetryEvent[] = [] + + /** + * @param logger The logger to send errors from the child process to + * @param enabled Whether to immediately start sending metrics to a server + */ + constructor(private logger: Logger, private enabled = false) { + if (enabled) { + this.startProcess() + } + } + + /** + * Start sending metrics to a server. This isn't enabled by default to allow + * the process to be set up before it's been confirmed that telemetry is + * permissible. + */ + enable() { + if (!this.enabled) { + this.startProcess() + + this.enabled = true + + // retroactively send all messages recorded up to this point + for (const event of this.eventBuffer) { + this.recordEvent(event) + } + this.eventBuffer = [] + } + } + + private startProcess() { + // by default, we disable native fetch in the process so that all the code + // we call out to works as we expect but we know it's safe to use fetch() + // here + const allowFetchArgv = process.execArgv.filter((arg) => arg !== '--no-experimental-fetch') + // we handle all communication with the HTTP telemetry endpoint in a child + // process so we can close the Tool Kit process before all telemetry events + // have been uploaded. this effectively allows us to run the telemetry + // upload as a background process and exit Tool Kit ASAP + // note that tests run the TypeScript file directly so we need to make sure + // to explicitly use the lib directory where the transpiled JavaScript is + // located + this.child = fork(`${__dirname}/../lib/child.mjs`, { + // we only care to log text from stderr + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + detached: true, + execArgv: allowFetchArgv, + // HACK:IM:20260107 for some reason the overridden endpoint in tests only + // gets picked up if we explicitly pass the environment here instead of + // implicitly? + env: { ...process.env } + }) + // print all errors (or anything else that's logged to stderr) as winston + // warnings + this.child.stderr?.pipe(createWritableLogger(this.logger, 'telemetry', 'warn')) + // we want to un-reference the child process so that this process can + // terminate before it. we don't want to do that in CI though so that we + // can ensure all the events have been recorded before ending the CI job. + if (!process.env.CI) { + this.child.unref() + } + } + + /** + * @param rootDetails Initial attributes to be included in all recorded events + */ + root(rootDetails: TelemetryAttributes = {}): TelemetryRecorder { + return new TelemetryRecorder(this, rootDetails) + } + + private recordEvent(event: TelemetryEvent) { + if (this.enabled) { + /* eslint-disable @typescript-eslint/no-non-null-assertion -- + * this.child will always be defined if this.enable has been called */ + if (this.child!.connected) { + this.child!.send(event) + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + } else { + this.eventBuffer.push(event) + } + } + + /** + * Disconnect from the child process. All future recorded events will be + * discarded. Calling this method is required to guarantee that the parent + * process can exit before the child process is finished. This will _not_ + * prevent the child process from asynchronously finishing the publishing of + * the remaining recorded events. + */ + disconnect() { + if (this.child?.connected) { + if (!process.env.CI) { + this.child.stderr?.destroy() + } + this.child.disconnect() + } + } +} + +/** Class to asynchronously record events via a `TelemetryProcess`. */ +export class TelemetryRecorder { + constructor(private process: TelemetryProcess, public attributes: TelemetryAttributes) {} + + /** + * Start sending metrics to a server managed by the `process`. This isn't + * enabled by default to allow the recorder's process to be set up before it's + * been confirmed that telemetry is permissible. Note that this forwards the + * `enable` call to the `process` and therefore will enable metrics for all + * of its child `TelemetryRecorder`s. + */ + enable(): void { + this.process.enable() + } + + /** + * Create a copy of this `TelemetryRecorder` but with new default attributes + * merged with its current ones that will be included in every recorded event. + * Only affects the returned `TelemetryRecorder`. + */ + scoped(details: TelemetryAttributes): TelemetryRecorder { + return new TelemetryRecorder(this.process, { ...this.attributes, ...details }) + } + + /** + * Record an event for a given namespace. The schema is strictly typed + * per-namespace as our backend server validates that the events are + * structured properly for both correctness and security purposes. + */ + recordEvent(namespace: N, details: NamespaceSchemas[N]) { + const event: TelemetryEvent = { + namespace: `dotcom-tool-kit.${namespace}`, + eventTimestamp: Date.now(), + data: { ...this.attributes, ...details } + } + this.process['recordEvent'](event) + } +} + +/** + * Mock client that can be used for testing or as a default fallback to a real + * telemetry client. Any events recorded to it or any of its scoped children + * will be discarded. + */ +export class MockTelemetryClient extends TelemetryRecorder { + constructor() { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any -- + * Safe to pass an undefined child process here because we override all + * the methods that use it. + **/ + super(undefined as any, {}) + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mocked function + override enable() {} + override scoped(_details: TelemetryAttributes): TelemetryRecorder { + return this + } + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mocked function + override recordEvent(_namespace: string, _details: NamespaceSchemas[Namespace]) {} +} + +export { TelemetryEvent } diff --git a/lib/telemetry/src/types.ts b/lib/telemetry/src/types.ts new file mode 100644 index 000000000..f85cce411 --- /dev/null +++ b/lib/telemetry/src/types.ts @@ -0,0 +1,12 @@ +export interface NamespaceSchemas { + 'tasks.completed': { success: boolean } +} +export type Namespace = keyof NamespaceSchemas + +export type TelemetryAttributes = Record + +export interface TelemetryEvent { + eventTimestamp: number + namespace: `dotcom-tool-kit.${N}` + data: TelemetryAttributes & NamespaceSchemas[N] +} diff --git a/lib/telemetry/test/index.test.ts b/lib/telemetry/test/index.test.ts new file mode 100644 index 000000000..379a09221 --- /dev/null +++ b/lib/telemetry/test/index.test.ts @@ -0,0 +1,216 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' + +import { expect, test } from '@jest/globals' +import winston, { type Logger } from 'winston' + +import { TelemetryProcess, TelemetryRecorder, type TelemetryEvent } from '../src' +import { ChildProcess, fork } from 'node:child_process' + +const logger = winston as unknown as Logger + +function createAndRegisterMockServer() { + const server = createServer() + server.listen() + process.env.TOOL_KIT_TELEMETRY_ENDPOINT = `http://localhost:${(server.address() as AddressInfo).port}` + return server +} + +async function listenForTelemetry(mockServer: Server, metricCount: number, responseTimeout?: number) { + let requestListener + const metrics = await new Promise((resolve) => { + const metrics: TelemetryEvent[][] = [] + requestListener = (req, res) => { + const bodyBuffer: Uint8Array[] = [] + req + .on('data', (chunk) => { + bodyBuffer.push(chunk) + }) + .on('end', () => { + const body = Buffer.concat(bodyBuffer).toString() + const parsed = JSON.parse(body) + metrics.push(parsed) + if (metrics.flat().length >= metricCount) { + resolve(metrics) + } + }) + + const sendResponse = () => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('metric received\n') + } + responseTimeout ? setTimeout(sendResponse, responseTimeout) : sendResponse() + } + mockServer.on('request', requestListener) + }) + mockServer.removeListener('request', requestListener) + + expect(metrics.flat()).toHaveLength(metricCount) + return metrics +} + +describe('attribute handling', () => { + const metricsMock = jest.fn() + const mockProcessor = { + recordEvent: metricsMock + } as unknown as TelemetryProcess + beforeEach(() => metricsMock.mockClear()) + + test('event attribute included', () => { + const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' }) + recorder.recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ foo: 'bar' }) }) + ) + }) + + test('parent attributes inherited', () => { + const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' }) + const child = recorder.scoped({ baz: 'qux' }) + child.recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ foo: 'bar', baz: 'qux' }) }) + ) + }) + + test('grandparent attributes inherited', () => { + const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' }) + const grandchild = recorder.scoped({ baz: 'qux' }).scoped({ test: 'pass' }) + grandchild.recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ foo: 'bar', baz: 'qux', test: 'pass' }) }) + ) + }) + + test('parent attributes overridable', () => { + const recorder = new TelemetryRecorder(mockProcessor, { foo: 'bar' }) + const child = recorder.scoped({ foo: 'baz' }) + child.recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ foo: 'baz' }) }) + ) + }) + + test("can't override event metadata", () => { + const recorder = new TelemetryRecorder(mockProcessor, { namespace: 'foo', eventTimestamp: '137' }) + recorder.recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' }) + ) + expect(metricsMock).not.toHaveBeenCalledWith(expect.objectContaining({ eventTimestamp: '137' })) + }) +}) + +describe('communication with child', () => { + let mockServer: Server + let telemetryChildProcess: ChildProcess + + beforeEach(() => { + mockServer = createAndRegisterMockServer() + telemetryChildProcess = fork(`${__dirname}/metricsProcess.mjs`, { env: { ...process.env } }) + }) + afterEach(() => { + mockServer.close() + telemetryChildProcess.kill() + }) + + test('metrics are still sent after parent has exited', async () => { + telemetryChildProcess.send({ action: 'send' }) + telemetryChildProcess.send({ action: 'send' }) + telemetryChildProcess.send({ action: 'disconnect' }) + // first metric will only finish sending once we receive it here + const metrics = await listenForTelemetry(mockServer, 2, 10) + expect(metrics).toHaveLength(2) + }) +}) + +describe('conditionally enabled', () => { + const metricsMock = jest.fn() + jest.doMock('node:child_process', () => ({ + fork: jest.fn(() => ({ + connected: true, + send: metricsMock, + unref: jest.fn() + })) + })) + /* eslint-disable-next-line @typescript-eslint/no-var-requires -- + * use a require here to include the mocked module + **/ + const { TelemetryProcess }: typeof import('../src') = require('../lib') + + beforeEach(() => metricsMock.mockClear()) + + test('no metrics are sent by default', () => { + const telemetryProcess = new TelemetryProcess(logger) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + expect(metricsMock).not.toHaveBeenCalled() + }) + + test('metrics are sent when enabled', () => { + const telemetryProcess = new TelemetryProcess(logger, true) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + expect(metricsMock).toHaveBeenCalled() + }) + + test('recorded metrics are back-sent once telemetry is enabled', () => { + const telemetryProcess = new TelemetryProcess(logger, false) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + telemetryProcess.enable() + expect(metricsMock).toHaveBeenCalledTimes(3) + }) +}) + +describe('metrics sent', () => { + let mockServer: Server + let telemetryProcess: TelemetryProcess + beforeEach(() => { + mockServer = createAndRegisterMockServer() + telemetryProcess = new TelemetryProcess(logger, true) + }) + afterEach(() => { + mockServer.close() + telemetryProcess.disconnect() + jest.useRealTimers() + }) + + test('a metric is sent successfully', async () => { + const listeningPromise = listenForTelemetry(mockServer, 1) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + const metrics = await listeningPromise + expect(metrics).toEqual([[expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' })]]) + }) + + // TODO:IM:20260107 enable this test once we have multiple different metric types + test.skip('metrics of different types are sent successfully', async () => { + const listeningPromise = listenForTelemetry(mockServer, 2) + const recorder = telemetryProcess.root() + recorder.recordEvent('tasks.completed', { success: true }) + recorder.recordEvent('tasks.completed', { success: true }) + const metrics = await listeningPromise + expect(metrics.flat()).toEqual([ + expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' }), + expect.objectContaining({ namespace: 'dotcom-tool-kit.tasks.completed' }) + ]) + }) + + test('buffers multiple metrics sent together', async () => { + const listeningPromise = listenForTelemetry(mockServer, 3, 10) + const recorder = telemetryProcess.root() + recorder.recordEvent('tasks.completed', { success: true }) + recorder.recordEvent('tasks.completed', { success: true }) + recorder.recordEvent('tasks.completed', { success: true }) + const metrics = await listeningPromise + expect(metrics[1]).toHaveLength(2) + }) + + test('uses timestamp from when recorded, not sent', async () => { + jest.useFakeTimers({ now: 0 }) + const listeningPromise = listenForTelemetry(mockServer, 1) + telemetryProcess.root().recordEvent('tasks.completed', { success: true }) + jest.setSystemTime(20) + const metrics = await listeningPromise + expect(metrics[0][0].eventTimestamp).toBe(0) + }) +}) diff --git a/lib/telemetry/test/metricsProcess.mjs b/lib/telemetry/test/metricsProcess.mjs new file mode 100644 index 000000000..7c6e4e9b4 --- /dev/null +++ b/lib/telemetry/test/metricsProcess.mjs @@ -0,0 +1,20 @@ +import winston from 'winston' + +import { TelemetryProcess } from '../lib/index.js' + +const telemetryProcess = new TelemetryProcess(winston, true) +const metrics = telemetryProcess.root() + +process.on('message', ({ action }) => { + switch (action) { + case 'send': + metrics.recordEvent('tasks.completed', { success: true }) + break + case 'disconnect': + // unreference everything so that this process's event loop can exit. + // explicitly disconnecting can mean that some messages are left unsent + telemetryProcess.child.unref() + telemetryProcess.child.channel?.unref() + telemetryProcess.child.stderr?.destroy() + } +}) diff --git a/lib/telemetry/tsconfig.json b/lib/telemetry/tsconfig.json new file mode 100644 index 000000000..546eb87aa --- /dev/null +++ b/lib/telemetry/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.settings.json", + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "references": [{ "path": "../logger" }] +} diff --git a/package-lock.json b/package-lock.json index a8c20dce8..6344d9965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "@dotcom-tool-kit/plugin": "^2.0.0", "@dotcom-tool-kit/schemas": "^1.9.0", "@dotcom-tool-kit/state": "^5.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@dotcom-tool-kit/validated": "^2.0.0", "endent": "^2.1.0", "lodash": "^4.17.21", @@ -136,6 +137,7 @@ }, "core/sandbox": { "version": "1.0.1-beta.0", + "extraneous": true, "license": "ISC", "dependencies": { "@dotcom-tool-kit/circleci": "^7.6.6", @@ -143,206 +145,6 @@ "dotcom-tool-kit": "^4.8.2" } }, - "core/sandbox/node_modules/@dotcom-tool-kit/base": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/base/-/base-1.3.1.tgz", - "integrity": "sha512-y8KGUwR9R5pzAry8P4IbnXHzjuwixgu9kNAemfYqg8aZRpNmGEOJ681nSPbFYQsKA1Bs5dX9vXQvyv6Bxiks4g==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/conflict": "^1.0.1", - "@dotcom-tool-kit/logger": "^4.2.2", - "@dotcom-tool-kit/validated": "^1.0.3", - "semver": "^7.7.3", - "winston": "^3.17.0" - }, - "peerDependencies": { - "zod": "^3.24.4" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/circleci": { - "version": "7.6.10", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/circleci/-/circleci-7.6.10.tgz", - "integrity": "sha512-ZPXgab8CYoVMv7N/jxPSGvuMzWQJixW2/Fuey0qp6rJ0eK0Hd3YoZb8PiMTvXB4biEKdUhdiIjbQdsxrmOWofQ==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/base": "^1.3.1", - "@dotcom-tool-kit/conflict": "^1.0.1", - "@dotcom-tool-kit/error": "^4.1.1", - "@dotcom-tool-kit/logger": "^4.2.2", - "@dotcom-tool-kit/state": "^4.3.2", - "jest-diff": "^29.7.0", - "lodash": "^4.17.21", - "tslib": "^2.8.1", - "yaml": "^2.8.0", - "zod": "^3.24.4" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - }, - "peerDependencies": { - "dotcom-tool-kit": "4.x", - "zod": "^3.24.4" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/config": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/config/-/config-1.1.1.tgz", - "integrity": "sha512-xtJjmn6D/BEsRLGy4NX09uD+DQOzxxxLXd3s0omvgIyjqK9sQCge/NVUS/PtkbyXbJaticH4L2mgEowWaKqnog==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/conflict": "^1.0.1", - "@dotcom-tool-kit/plugin": "^1.1.0", - "@dotcom-tool-kit/validated": "^1.0.3" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/conflict": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/conflict/-/conflict-1.0.1.tgz", - "integrity": "sha512-u8ha4ZxbkYtcG06HiBbz8ygeqCfynCL7ONM/SHMDYgWJZw4H9YqrTxc5PGrqTv+K2kL2/8qqmsO8fKi/2PFsCw==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/plugin": "^1.1.0" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/doppler": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/doppler/-/doppler-2.2.3.tgz", - "integrity": "sha512-IdzSwNseE8AjUzPCPwhwIj4cPjxJ9u5mvJ0VN6k5lQAu2u0xR4iysHQZKT2iin7tXFXref4vJlg5bbhwy8jrnw==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/error": "^4.1.1", - "@dotcom-tool-kit/logger": "^4.2.2", - "tslib": "^2.8.1", - "zod": "^3.24.4" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/error/-/error-4.1.1.tgz", - "integrity": "sha512-2IC3LxeiOeV7/jtvSYVqUXG8AC3ixDNQkO+Y8bjRReDX8seX30F5ED7I4OOocXlJrbeWhkeXYLoDVj5gqMKiQQ==", - "license": "ISC", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/logger": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/logger/-/logger-4.2.2.tgz", - "integrity": "sha512-Yc3akGAj572mpD9QGBgDevSNygotGcEdFYrgv4dwMMJDsScvRjuttbHrsaIfPh+tT7jGPAtKrsHU2KCEzv6wDQ==", - "license": "ISC", - "dependencies": { - "@apaleslimghost/boxen": "^5.1.3", - "@dotcom-tool-kit/error": "^4.1.1", - "ansi-regex": "^5.0.1", - "chalk": "^4.1.0", - "triple-beam": "^1.4.1", - "tslib": "^2.8.1", - "winston": "^3.17.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/node/-/node-4.4.1.tgz", - "integrity": "sha512-1f7r8EjfQ9uZZLdCgXXYyFPChn71/gB945eI1kiUCeh8VTLGGMa+C/GABbNJGoKaLDFYnV74UCjHB4qmjWzVZw==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/base": "^1.3.1", - "@dotcom-tool-kit/doppler": "^2.2.3", - "@dotcom-tool-kit/error": "^4.1.1", - "@dotcom-tool-kit/state": "^4.3.2", - "get-port": "^5.1.1", - "tslib": "^2.8.1", - "wait-port": "^1.1.0", - "zod": "^3.24.4" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - }, - "peerDependencies": { - "dotcom-tool-kit": "4.x" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/plugin/-/plugin-1.1.0.tgz", - "integrity": "sha512-G8IYLIgLlBmsTb+VtwIOpddIkSZB4/+jT1ra+6UtcpgHKGFpCwj5L116mVt9gq7duvSHpO5ApjyglcjfJjJ7LQ==", - "license": "ISC" - }, - "core/sandbox/node_modules/@dotcom-tool-kit/state": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/state/-/state-4.3.2.tgz", - "integrity": "sha512-Axs0yRTa138c865Hf25D6vhu1A4a+sSI76u/RP8zrlQU1+GDObQ3/wJn2rwngHA2qtGnIumZqGIweFoe+qQcjA==", - "license": "ISC", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/validated": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/validated/-/validated-1.0.3.tgz", - "integrity": "sha512-f6t1gU+OJG4RrXIhwbqoFN75qtwk+sYiL6iHbPKgD/dkLq/trvV4WfWNbdYkJCnyfOBATYh5CdqOf9cjospxCg==", - "license": "ISC", - "dependencies": { - "@dotcom-tool-kit/error": "^4.1.1" - } - }, - "core/sandbox/node_modules/@dotcom-tool-kit/wait-for-ok": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@dotcom-tool-kit/wait-for-ok/-/wait-for-ok-4.1.2.tgz", - "integrity": "sha512-wouZ7f/0YHvhvpx+nj/35swF7Cznis9qKqy60SnEit6HSx0qP0dx+XIgIrqR1XveDxRUZD9tmmHBh3EpV3L8Gg==", - "license": "ISC", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, - "core/sandbox/node_modules/dotcom-tool-kit": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/dotcom-tool-kit/-/dotcom-tool-kit-4.10.1.tgz", - "integrity": "sha512-dcSwDVxs3JUWoyDOAM+Qy9lq1xCtuJnDGe2sRi97YECpXB5YOmkReVaS8o7VZhkPiBpnS3y0r7F4SLi2x0RN0Q==", - "license": "MIT", - "dependencies": { - "@dotcom-tool-kit/base": "^1.3.1", - "@dotcom-tool-kit/config": "^1.1.1", - "@dotcom-tool-kit/conflict": "^1.0.1", - "@dotcom-tool-kit/error": "^4.1.1", - "@dotcom-tool-kit/logger": "^4.2.2", - "@dotcom-tool-kit/plugin": "^1.1.0", - "@dotcom-tool-kit/schemas": "^1.9.0", - "@dotcom-tool-kit/state": "^4.3.2", - "@dotcom-tool-kit/validated": "^1.0.3", - "@dotcom-tool-kit/wait-for-ok": "^4.1.2", - "endent": "^2.1.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "pluralize": "^8.0.0", - "pretty-format": "^29.7.0", - "tslib": "^2.8.1", - "yaml": "^2.8.0", - "zod-validation-error": "^3.4.1" - }, - "bin": { - "dotcom-tool-kit": "bin/run" - }, - "engines": { - "node": "18.x || 20.x || 22.x" - } - }, "lib/base": { "name": "@dotcom-tool-kit/base", "version": "2.0.0", @@ -350,6 +152,7 @@ "dependencies": { "@dotcom-tool-kit/conflict": "^2.0.0", "@dotcom-tool-kit/logger": "^5.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@dotcom-tool-kit/validated": "^2.0.0", "semver": "^7.7.3", "winston": "^3.17.0" @@ -462,6 +265,20 @@ "node": ">=20.x" } }, + "lib/telemetry": { + "name": "@dotcom-tool-kit/telemetry", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@dotcom-tool-kit/logger": "^5.0.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0" + }, + "engines": { + "node": ">=20.x" + } + }, "lib/validated": { "name": "@dotcom-tool-kit/validated", "version": "2.0.0", @@ -5795,6 +5612,10 @@ "resolved": "lib/state", "link": true }, + "node_modules/@dotcom-tool-kit/telemetry": { + "resolved": "lib/telemetry", + "link": true + }, "node_modules/@dotcom-tool-kit/typescript": { "resolved": "plugins/typescript", "link": true @@ -27033,10 +26854,6 @@ "version": "2.1.2", "license": "MIT" }, - "node_modules/sandbox": { - "resolved": "core/sandbox", - "link": true - }, "node_modules/sax": { "version": "1.2.1", "license": "ISC", @@ -31047,6 +30864,7 @@ }, "devDependencies": { "@dotcom-tool-kit/plugin": "^2.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.20", @@ -31070,6 +30888,7 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@dotcom-tool-kit/telemetry": "^1.0.0", "winston": "^3.17.0" }, "engines": { @@ -31088,6 +30907,9 @@ "@dotcom-tool-kit/npm": "^5.0.0", "tslib": "^2.8.1" }, + "devDependencies": { + "@dotcom-tool-kit/telemetry": "^1.0.0" + }, "engines": { "node": ">=20.x" }, diff --git a/plugins/circleci-deploy/package.json b/plugins/circleci-deploy/package.json index 973f888f6..d00bc7caf 100644 --- a/plugins/circleci-deploy/package.json +++ b/plugins/circleci-deploy/package.json @@ -28,6 +28,7 @@ "extends": "../../package.json" }, "devDependencies": { + "@dotcom-tool-kit/telemetry": "^1.0.0", "winston": "^3.17.0" }, "peerDependencies": { diff --git a/plugins/circleci-deploy/test/circleci-deploy.test.ts b/plugins/circleci-deploy/test/circleci-deploy.test.ts index 04a156b0a..7563cd3fa 100644 --- a/plugins/circleci-deploy/test/circleci-deploy.test.ts +++ b/plugins/circleci-deploy/test/circleci-deploy.test.ts @@ -1,6 +1,7 @@ import * as YAML from 'yaml' import path from 'path' import CircleCi from '@dotcom-tool-kit/circleci/lib/circleci-config' +import { MockTelemetryClient } from '@dotcom-tool-kit/telemetry' import winston, { Logger } from 'winston' import { loadConfig } from 'dotcom-tool-kit/lib/config' import { loadHookInstallations } from 'dotcom-tool-kit/lib/install' @@ -11,7 +12,8 @@ describe('circleci-deploy', () => { describe('config integration test', () => { it('should generate a .circleci/config.yml with the base config from circleci-deploy/.toolkitrc.yml', async () => { const config = await loadConfig(logger, { root: path.resolve(__dirname, 'files', 'configs', 'base') }) - const hookInstallationsPromise = loadHookInstallations(logger, config).then((validated) => + const metrics = new MockTelemetryClient() + const hookInstallationsPromise = loadHookInstallations(logger, metrics, config).then((validated) => validated.unwrap('hooks were invalid') ) diff --git a/plugins/circleci-npm/package.json b/plugins/circleci-npm/package.json index b36331bda..e10b1bbd5 100644 --- a/plugins/circleci-npm/package.json +++ b/plugins/circleci-npm/package.json @@ -30,5 +30,8 @@ }, "engines": { "node": ">=20.x" + }, + "devDependencies": { + "@dotcom-tool-kit/telemetry": "^1.0.0" } } diff --git a/plugins/circleci-npm/test/circleci-npm.test.ts b/plugins/circleci-npm/test/circleci-npm.test.ts index 71c76de3b..d41f6a040 100644 --- a/plugins/circleci-npm/test/circleci-npm.test.ts +++ b/plugins/circleci-npm/test/circleci-npm.test.ts @@ -1,6 +1,7 @@ import * as YAML from 'yaml' import path from 'path' import CircleCi from '@dotcom-tool-kit/circleci/lib/circleci-config' +import { MockTelemetryClient } from '@dotcom-tool-kit/telemetry' import winston, { Logger } from 'winston' import { loadConfig } from 'dotcom-tool-kit/lib/config' import { loadHookInstallations } from 'dotcom-tool-kit/lib/install' @@ -11,7 +12,8 @@ describe('circleci-npm', () => { describe('config integration test', () => { it('should generate a .circleci/config.yml with the base config from circleci-npm/.toolkitrc.yml', async () => { const config = await loadConfig(logger, { root: path.resolve(__dirname, '..') }) - const hookInstallationsPromise = loadHookInstallations(logger, config).then((validated) => + const metrics = new MockTelemetryClient() + const hookInstallationsPromise = loadHookInstallations(logger, metrics, config).then((validated) => validated.unwrap('hooks were invalid') ) diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index e8a790d0a..b34af14e5 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -30,6 +30,7 @@ "homepage": "https://github.com/financial-times/dotcom-tool-kit/tree/main/plugins/circleci", "devDependencies": { "@dotcom-tool-kit/plugin": "^2.0.0", + "@dotcom-tool-kit/telemetry": "^1.0.0", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.20", diff --git a/plugins/circleci/test/circleci-config.test.ts b/plugins/circleci/test/circleci-config.test.ts index c1ce0b089..3418c361b 100644 --- a/plugins/circleci/test/circleci-config.test.ts +++ b/plugins/circleci/test/circleci-config.test.ts @@ -6,6 +6,7 @@ import winston, { Logger } from 'winston' import * as YAML from 'yaml' import { loadConfig } from 'dotcom-tool-kit/lib/config' import { loadHookInstallations } from 'dotcom-tool-kit/lib/install' +import { MockTelemetryClient } from '@dotcom-tool-kit/telemetry' import CircleCi from '../lib/circleci-config' @@ -722,7 +723,8 @@ describe('CircleCI config hook', () => { // because option parsing doesn't work if loading the circleci toolkitrc directly const config = await loadConfig(logger, { root: path.resolve(__dirname, 'files', 'configs', 'base') }) - const hookInstallationsPromise = loadHookInstallations(logger, config).then((validated) => + const metrics = new MockTelemetryClient() + const hookInstallationsPromise = loadHookInstallations(logger, metrics, config).then((validated) => validated.unwrap('hooks were invalid') ) diff --git a/plugins/monorepo/src/tasks/workspace-command.ts b/plugins/monorepo/src/tasks/workspace-command.ts index ea156698d..1321dd820 100644 --- a/plugins/monorepo/src/tasks/workspace-command.ts +++ b/plugins/monorepo/src/tasks/workspace-command.ts @@ -94,7 +94,8 @@ ${configsWithCommand.map(({ packageId }) => `- ${styles.plugin(packageId)}`).joi this.logger.child({ packageId }), config, [configuredCommand], - files + files, + this.metrics ).catch((error) => { error.name = `${styles.plugin(packageId)} → ${error.name}` throw error diff --git a/plugins/monorepo/test/__snapshots__/load-workspace-configs.test.ts.snap b/plugins/monorepo/test/__snapshots__/load-workspace-configs.test.ts.snap index 9eada1089..0f1bad42a 100644 --- a/plugins/monorepo/test/__snapshots__/load-workspace-configs.test.ts.snap +++ b/plugins/monorepo/test/__snapshots__/load-workspace-configs.test.ts.snap @@ -751,6 +751,7 @@ exports[`LoadWorkspaceConfigs should load multiple tool kit configs from workspa }, "options": { "allowNativeFetch": false, + "enableTelemetry": false, }, "plugin": { "children": [ @@ -1543,6 +1544,7 @@ exports[`LoadWorkspaceConfigs should load multiple tool kit configs from workspa }, "options": { "allowNativeFetch": false, + "enableTelemetry": false, }, "plugin": { "children": [ diff --git a/tsconfig.json b/tsconfig.json index 9651aae39..8cba9052b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -126,6 +126,9 @@ }, { "path": "plugins/parallel" + }, + { + "path": "lib/telemetry" } ] }