From fcdea779f0f95e635a100fbf62a1b7e3d31a45d4 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Mon, 3 Nov 2025 11:57:40 +0000 Subject: [PATCH 1/7] feat(telemetry): add package to send client side metrics This package allows us to send metrics from Tool Kit invocations to a backend server, currently planned to be an instance of Vector running in AWS. We can use these metrics to get visibility for how people use Tool Kit, and where things may be going wrong. For now, I've only added one metric, which tracks what Tool Kit tasks are run and whether they completed successfully. The telemetry package uses a child process to allow us to send the metrics asynchronously and in the background once the main Tool Kit process exits, meaning there is minimal impact on the time it takes for Tool Kit commands to run on developer machines. Children of the metrics class can be created with arbitrary attributes overridden, and these classes are passed around between functions and stored as fields in Task and Hook classes, allowing important metadata, such as whether we're in CI or the name of the task currently running, to always be included in recorded events and scoped logically. --- core/cli/bin/run | 14 +- core/cli/package.json | 1 + core/cli/src/index.ts | 5 +- core/cli/src/install.ts | 22 +- core/cli/src/tasks.ts | 38 ++- core/cli/test/index.test.ts | 4 +- core/cli/tsconfig.json | 3 + lib/base/package.json | 1 + lib/base/src/hook.ts | 10 +- lib/base/src/task.ts | 10 +- lib/base/tsconfig.json | 3 + lib/telemetry/package.json | 31 +++ lib/telemetry/src/child.mts | 46 ++++ lib/telemetry/src/index.ts | 127 ++++++++++ lib/telemetry/src/types.ts | 12 + lib/telemetry/tsconfig.json | 8 + package-lock.json | 227 ++---------------- plugins/circleci-deploy/package.json | 1 + .../test/circleci-deploy.test.ts | 4 +- plugins/circleci-npm/package.json | 3 + .../circleci-npm/test/circleci-npm.test.ts | 4 +- plugins/circleci/package.json | 1 + plugins/circleci/test/circleci-config.test.ts | 4 +- .../monorepo/src/tasks/workspace-command.ts | 3 +- tsconfig.json | 3 + 25 files changed, 353 insertions(+), 232 deletions(-) create mode 100644 lib/telemetry/package.json create mode 100644 lib/telemetry/src/child.mts create mode 100644 lib/telemetry/src/index.ts create mode 100644 lib/telemetry/src/types.ts create mode 100644 lib/telemetry/tsconfig.json 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..8372413ce 100644 --- a/core/cli/src/index.ts +++ b/core/cli/src/index.ts @@ -3,6 +3,7 @@ import type { Logger } from 'winston' import util from 'util' import { formatPluginTree } from './messages' import { loadHookInstallations } from './install' +import { TelemetryRecorder } from '@dotcom-tool-kit/telemetry' export { runCommands } from './tasks' export { shouldDisableNativeFetch } from './fetch' @@ -22,9 +23,9 @@ 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') + 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..77b70a2f3 100644 --- a/core/cli/src/install.ts +++ b/core/cli/src/install.ts @@ -13,6 +13,7 @@ import { findConflicts, withoutConflicts } from '@dotcom-tool-kit/conflict' import { formatUninstalledHooks } from './messages' import { importEntryPoint } from './plugin/entry-point' import { runInit } from './init' +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 +52,7 @@ export const loadHookEntrypoints = async ( export const loadHookInstallations = async ( logger: Logger, + metrics: TelemetryRecorder, config: ValidConfig ): Promise> => { const hookClassResults = await loadHookEntrypoints(logger, config) @@ -83,17 +85,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 +122,13 @@ 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() }) await runInit(logger, config) const errors: Error[] = [] - const hooks = (await loadHookInstallations(logger, config)).unwrap( + const hooks = (await loadHookInstallations(logger, metrics, config)).unwrap( 'hooks were found to be invalid when installing' ) diff --git a/core/cli/src/tasks.ts b/core/cli/src/tasks.ts index ab0d86732..c1b527d9d 100644 --- a/core/cli/src/tasks.ts +++ b/core/cli/src/tasks.ts @@ -15,6 +15,7 @@ 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 +25,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 +47,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 +79,7 @@ export function handleTaskErrors(errors: ErrorSummary[], command: string) { export async function runTasks( logger: Logger, + metrics: TelemetryRecorder, config: ValidConfig, tasks: Task[], command: string, @@ -84,9 +92,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 +109,7 @@ export async function runTasks( task: task.id, error: error as Error }) + scoped.recordEvent('tasks.completed', { success: false }) } } @@ -111,10 +122,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 +142,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 +155,18 @@ 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() }) - return runCommandsFromConfig(logger, config, commands, files) + return runCommandsFromConfig(logger, config, commands, files, metrics) } 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/telemetry/package.json b/lib/telemetry/package.json new file mode 100644 index 000000000..151f36318 --- /dev/null +++ b/lib/telemetry/package.json @@ -0,0 +1,31 @@ +{ + "name": "@dotcom-tool-kit/telemetry", + "version": "1.0.0", + "description": "", + "main": "lib", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "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" + } +} 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..822f12017 --- /dev/null +++ b/lib/telemetry/src/index.ts @@ -0,0 +1,127 @@ +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 { + child: ChildProcess + + constructor(private logger: Logger) { + // 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(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) + } + + /** + * 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) {} + + /** + * 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 telemetryChild = this.process.child + if (telemetryChild.connected) { + const event: TelemetryEvent = { + namespace: `dotcom-tool-kit.${namespace}`, + eventTimestamp: Date.now(), + data: { ...this.attributes, ...details } + } + telemetryChild.send(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, {}) + } + + 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/tsconfig.json b/lib/telemetry/tsconfig.json new file mode 100644 index 000000000..e393fdc3c --- /dev/null +++ b/lib/telemetry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.settings.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "references": [{ "path": "../logger" }] +} diff --git a/package-lock.json b/package-lock.json index a8c20dce8..de49e4342 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,17 @@ "node": ">=20.x" } }, + "lib/telemetry": { + "name": "@dotcom-tool-kit/telemetry", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@dotcom-tool-kit/logger": "^5.0.0" + }, + "engines": { + "node": ">=20.x" + } + }, "lib/validated": { "name": "@dotcom-tool-kit/validated", "version": "2.0.0", @@ -5795,6 +5609,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 +26851,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 +30861,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 +30885,7 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@dotcom-tool-kit/telemetry": "^1.0.0", "winston": "^3.17.0" }, "engines": { @@ -31088,6 +30904,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/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" } ] } From 49bc3e97195f35f3504c4d042bfd7951e8ebf291 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Mon, 24 Nov 2025 10:07:26 +0000 Subject: [PATCH 2/7] feat(plugin): add systemCode option to root Tool Kit options --- lib/plugin/src/root-schema.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/plugin/src/root-schema.ts b/lib/plugin/src/root-schema.ts index cba8868de..4d21b784b 100644 --- a/lib/plugin/src/root-schema.ts +++ b/lib/plugin/src/root-schema.ts @@ -1,6 +1,11 @@ import * as z from 'zod' export const RootSchema = z.object({ - allowNativeFetch: z.boolean().default(false) + allowNativeFetch: z.boolean().default(false), + // 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 From 44b89bfc1b08f6259013e59c3b18f8e80f585210 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Mon, 24 Nov 2025 10:07:26 +0000 Subject: [PATCH 3/7] feat(core): try to infer system code for metrics attribute --- core/cli/src/install.ts | 9 ++++++++- core/cli/src/systemCode.ts | 28 ++++++++++++++++++++++++++++ core/cli/src/tasks.ts | 9 ++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 core/cli/src/systemCode.ts diff --git a/core/cli/src/install.ts b/core/cli/src/install.ts index 77b70a2f3..65f2cd4b0 100644 --- a/core/cli/src/install.ts +++ b/core/cli/src/install.ts @@ -13,6 +13,7 @@ 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 { TelemetryRecorder } from '@dotcom-tool-kit/telemetry' // implementation of the Array#every method that supports asynchronous predicates @@ -127,8 +128,14 @@ export default async function installHooks(logger: Logger, metrics: TelemetryRec 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, metrics, 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 c1b527d9d..9ecf43f5c 100644 --- a/core/cli/src/tasks.ts +++ b/core/cli/src/tasks.ts @@ -10,6 +10,7 @@ import { styles } from '@dotcom-tool-kit/logger' import { shouldDisableNativeFetch } from './fetch' import { runInit } from './init' import { formatInvalidOption } from './messages' +import { guessSystemCode } from './systemCode' 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' @@ -168,5 +169,11 @@ export async function runCommands( ): Promise { const config = await loadConfig(logger, { root: process.cwd() }) - return runCommandsFromConfig(logger, config, commands, files, metrics) + const systemCode = await guessSystemCode(config) + let scoped = metrics + if (systemCode) { + scoped = metrics.scoped({ systemCode }) + } + + return runCommandsFromConfig(logger, config, commands, files, scoped) } From be622b9a95c42f50f900ce6436c8a9531b07e69f Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Wed, 7 Jan 2026 10:02:56 +0000 Subject: [PATCH 4/7] test(telemetry): add tests --- lib/telemetry/jest.config.js | 5 + lib/telemetry/package.json | 5 +- lib/telemetry/test/index.test.ts | 176 ++++++++++++++++++++++++++ lib/telemetry/test/metricsProcess.mjs | 20 +++ lib/telemetry/tsconfig.json | 1 + package-lock.json | 3 + 6 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 lib/telemetry/jest.config.js create mode 100644 lib/telemetry/test/index.test.ts create mode 100644 lib/telemetry/test/metricsProcess.mjs 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 index 151f36318..a8a7976c8 100644 --- a/lib/telemetry/package.json +++ b/lib/telemetry/package.json @@ -4,7 +4,7 @@ "description": "", "main": "lib", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "cd ../../ ; npx jest --silent --projects lib/telemetry" }, "keywords": [], "author": "FT.com Platforms Team ", @@ -27,5 +27,8 @@ }, "dependencies": { "@dotcom-tool-kit/logger": "^5.0.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0" } } diff --git a/lib/telemetry/test/index.test.ts b/lib/telemetry/test/index.test.ts new file mode 100644 index 000000000..442f2af50 --- /dev/null +++ b/lib/telemetry/test/index.test.ts @@ -0,0 +1,176 @@ +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 = { child: { connected: true, send: 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('metrics sent', () => { + let mockServer: Server + let telemetryProcess: TelemetryProcess + beforeEach(() => { + mockServer = createAndRegisterMockServer() + telemetryProcess = new TelemetryProcess(logger) + }) + 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..4a77e1590 --- /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) +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 index e393fdc3c..546eb87aa 100644 --- a/lib/telemetry/tsconfig.json +++ b/lib/telemetry/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../../tsconfig.settings.json", + "include": ["src/**/*"], "compilerOptions": { "outDir": "lib", "rootDir": "src" diff --git a/package-lock.json b/package-lock.json index de49e4342..6344d9965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -272,6 +272,9 @@ "dependencies": { "@dotcom-tool-kit/logger": "^5.0.0" }, + "devDependencies": { + "@jest/globals": "^29.7.0" + }, "engines": { "node": ">=20.x" } From e29de44241b96e27212a3fc234689d3195d9fd92 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Fri, 9 Jan 2026 15:43:42 +0000 Subject: [PATCH 5/7] feat(telemetry): don't send metrics to the server until enabled This allows us to create a TelemetryProcess and start tracking metrics early on in a Tool Kit invocation before we've decided whether we should actually send the telemetry or not (i.e., whether the telemetry has been opted into or not.) The child process is only forked once the telemetry has been enabled to avoid unnecessary work. --- lib/telemetry/src/index.ts | 80 +++++++++++++++++++++++---- lib/telemetry/test/index.test.ts | 44 ++++++++++++++- lib/telemetry/test/metricsProcess.mjs | 2 +- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/lib/telemetry/src/index.ts b/lib/telemetry/src/index.ts index 822f12017..ed3586d23 100644 --- a/lib/telemetry/src/index.ts +++ b/lib/telemetry/src/index.ts @@ -13,9 +13,42 @@ import type { TelemetryEvent, TelemetryAttributes, Namespace, NamespaceSchemas } * typically created with this class's `.root()` method. */ export class TelemetryProcess { - child: ChildProcess + /** 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[] = [] - constructor(private logger: Logger) { + /** + * @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 @@ -39,7 +72,7 @@ export class TelemetryProcess { }) // print all errors (or anything else that's logged to stderr) as winston // warnings - this.child.stderr?.pipe(createWritableLogger(logger, 'telemetry', 'warn')) + 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. @@ -55,6 +88,19 @@ export class TelemetryProcess { 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 @@ -63,7 +109,7 @@ export class TelemetryProcess { * the remaining recorded events. */ disconnect() { - if (this.child.connected) { + if (this.child?.connected) { if (!process.env.CI) { this.child.stderr?.destroy() } @@ -76,6 +122,17 @@ export class 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. @@ -91,15 +148,12 @@ export class TelemetryRecorder { * structured properly for both correctness and security purposes. */ recordEvent(namespace: N, details: NamespaceSchemas[N]) { - const telemetryChild = this.process.child - if (telemetryChild.connected) { - const event: TelemetryEvent = { - namespace: `dotcom-tool-kit.${namespace}`, - eventTimestamp: Date.now(), - data: { ...this.attributes, ...details } - } - telemetryChild.send(event) + const event: TelemetryEvent = { + namespace: `dotcom-tool-kit.${namespace}`, + eventTimestamp: Date.now(), + data: { ...this.attributes, ...details } } + this.process['recordEvent'](event) } } @@ -117,6 +171,8 @@ export class MockTelemetryClient extends TelemetryRecorder { super(undefined as any, {}) } + // eslint-disable-next-line @typescript-eslint/no-empty-function -- mocked function + override enable() {} override scoped(_details: TelemetryAttributes): TelemetryRecorder { return this } diff --git a/lib/telemetry/test/index.test.ts b/lib/telemetry/test/index.test.ts index 442f2af50..379a09221 100644 --- a/lib/telemetry/test/index.test.ts +++ b/lib/telemetry/test/index.test.ts @@ -51,7 +51,9 @@ async function listenForTelemetry(mockServer: Server, metricCount: number, respo describe('attribute handling', () => { const metricsMock = jest.fn() - const mockProcessor = { child: { connected: true, send: metricsMock } } as unknown as TelemetryProcess + const mockProcessor = { + recordEvent: metricsMock + } as unknown as TelemetryProcess beforeEach(() => metricsMock.mockClear()) test('event attribute included', () => { @@ -122,12 +124,50 @@ describe('communication with child', () => { }) }) +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) + telemetryProcess = new TelemetryProcess(logger, true) }) afterEach(() => { mockServer.close() diff --git a/lib/telemetry/test/metricsProcess.mjs b/lib/telemetry/test/metricsProcess.mjs index 4a77e1590..7c6e4e9b4 100644 --- a/lib/telemetry/test/metricsProcess.mjs +++ b/lib/telemetry/test/metricsProcess.mjs @@ -2,7 +2,7 @@ import winston from 'winston' import { TelemetryProcess } from '../lib/index.js' -const telemetryProcess = new TelemetryProcess(winston) +const telemetryProcess = new TelemetryProcess(winston, true) const metrics = telemetryProcess.root() process.on('message', ({ action }) => { From 2cccf957604a94dcc2baf3335c4b304631c98b65 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Fri, 9 Jan 2026 15:43:42 +0000 Subject: [PATCH 6/7] feat(telemetry): add option to opt-in to telemetry This is opt-in for now whilst we set up and test integration with a backend server (mostly with our own projects) but will be an opt-out option in a future (non-breaking) version. --- core/cli/src/index.ts | 3 +++ core/cli/src/install.ts | 3 +++ core/cli/src/tasks.ts | 2 ++ core/cli/src/telemetry.ts | 11 +++++++++++ lib/plugin/src/root-schema.ts | 6 ++++++ 5 files changed, 25 insertions(+) create mode 100644 core/cli/src/telemetry.ts diff --git a/core/cli/src/index.ts b/core/cli/src/index.ts index 8372413ce..7f36513c8 100644 --- a/core/cli/src/index.ts +++ b/core/cli/src/index.ts @@ -3,6 +3,8 @@ 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' @@ -25,6 +27,7 @@ export async function printConfig(logger: Logger): Promise { export async function printMergedOptions(logger: Logger, metrics: TelemetryRecorder): Promise { const config = await loadConfig(logger, { validate: true, root: process.cwd() }) + enableTelemetry(metrics, config.pluginOptions['app root'].options as RootOptions) const hookInstallations = (await loadHookInstallations(logger, metrics, config)).unwrap('invalid hooks') const mergedOptions = { diff --git a/core/cli/src/install.ts b/core/cli/src/install.ts index 65f2cd4b0..cce7d4b18 100644 --- a/core/cli/src/install.ts +++ b/core/cli/src/install.ts @@ -7,6 +7,7 @@ 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' @@ -14,6 +15,7 @@ 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 @@ -125,6 +127,7 @@ export async function checkInstall( 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) diff --git a/core/cli/src/tasks.ts b/core/cli/src/tasks.ts index 9ecf43f5c..876720565 100644 --- a/core/cli/src/tasks.ts +++ b/core/cli/src/tasks.ts @@ -11,6 +11,7 @@ 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' @@ -168,6 +169,7 @@ export async function runCommands( 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 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/lib/plugin/src/root-schema.ts b/lib/plugin/src/root-schema.ts index 4d21b784b..ce3ea12eb 100644 --- a/lib/plugin/src/root-schema.ts +++ b/lib/plugin/src/root-schema.ts @@ -2,6 +2,12 @@ import * as z from 'zod' export const RootSchema = z.object({ 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() From e265e931685015dff23edd3f66947a069a46db57 Mon Sep 17 00:00:00 2001 From: Ivo Murrell Date: Fri, 9 Jan 2026 15:43:42 +0000 Subject: [PATCH 7/7] test: update snapshots for new root plugin option --- core/cli/test/__snapshots__/config.test.ts.snap | 3 +++ .../test/__snapshots__/load-workspace-configs.test.ts.snap | 2 ++ 2 files changed, 5 insertions(+) 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/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": [