From 983a78c43042a81fef48760cd4ed03b203b06bc6 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 9 Mar 2026 19:38:59 -0700 Subject: [PATCH 01/31] feat: enhance telemetry-lib with agent detection and context tracking - Added `getInvocationContext` function to determine if the CLI is invoked by an AI agent or a human based on environment variables. - Updated `trackEvent` to include `invocation_context` and `agent_name` in the payload sent to the telemetry service. - Expanded test coverage for `trackEvent` and `getInvocationContext` to validate new functionality. --- src/telemetry-lib.js | 47 ++++++++++++++++++++++++- test/telemetry-lib.test.js | 70 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index f40bfac..cc31193 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -19,6 +19,47 @@ const debug = require('debug')('aio-telemetry:telemetry-lib') let isDisabledForCommand = false +/** + * Environment variables checked for agent detection (proposed standard first, then tool-specific). + * Used for metrics only. See aio-cli README "Agent detection" for full list. + */ +const AGENT_ENV_VARS = [ + { env: 'AGENT', name: (v) => (v && v !== '1' && v !== 'true' ? String(v).toLowerCase() : 'generic') }, + { env: 'AI_AGENT', name: (v) => (v && v !== '1' && v !== 'true' ? String(v).toLowerCase() : 'generic') }, + { env: 'AIO_AGENT', name: () => 'aio-opt-in' }, + { env: 'AIO_INVOCATION_CONTEXT', name: (v) => (v === 'agent' ? 'aio-opt-in' : null) }, + { env: 'CURSOR_AGENT', name: () => 'cursor' }, + { env: 'CLAUDECODE', name: () => 'claude' }, + { env: 'CLAUDE_CODE', name: () => 'claude' }, + { env: 'GEMINI_CLI', name: () => 'gemini' }, + { env: 'CODEX_SANDBOX', name: () => 'codex' }, + { env: 'AUGMENT_AGENT', name: () => 'augment' }, + { env: 'CLINE_ACTIVE', name: () => 'cline' }, + { env: 'OPENCODE_CLIENT', name: () => 'opencode' }, + { env: 'REPL_ID', name: () => 'replit' } +] + +/** + * Detects whether the CLI is being invoked by an AI agent (vs a human) using env vars. + * Used for metrics only. + * + * @param {object} [env] - Environment object to read (defaults to process.env when omitted). + * @returns {{ isAgent: boolean, agentName: string|null }} + */ +function getInvocationContext (env) { + const envToUse = env !== undefined ? env : process.env + for (const { env: key, name } of AGENT_ENV_VARS) { + const value = envToUse[key] + if (value !== undefined && value !== '') { + const agentName = typeof name === 'function' ? name(value) : name + if (agentName) { + return { isAgent: true, agentName } + } + } + } + return { isAgent: false, agentName: null } +} + const osNameVersion = osName() // this is set by the init hook, ex. @adobe/aio-cli@8.2.0 @@ -69,6 +110,7 @@ async function trackEvent (eventType, eventData = '') { } else { const clientId = getClientId() const timestamp = Date.now() + const invocationContext = getInvocationContext() const fetchConfig = { method: 'POST', headers: fetchHeaders, @@ -85,7 +127,9 @@ async function trackEvent (eventType, eventData = '') { commandFlags: prerunEvent.flags.toString(), commandSuccess: eventType !== 'command-error', nodeVersion: process.version, - osNameVersion + osNameVersion, + invocation_context: invocationContext.isAgent ? 'agent' : 'human', + agent_name: invocationContext.agentName } }) } @@ -109,6 +153,7 @@ function trackPrerun (command, flags, start) { } module.exports = { + getInvocationContext, init: (versionString, binName, remoteConf = {}) => { global.commandHookStartTime = Date.now() rootCliVersion = versionString diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 45f4255..60d8293 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -51,4 +51,74 @@ describe('telemetry-lib', () => { expect(fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ body: expect.stringContaining('"clientId":"clientidxyz"') })) }) + + test('trackEvent includes invocation_context and agent_name in payload', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binTest') + await telemetryLib.trackEvent('postrun') + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body._adobeio).toHaveProperty('invocation_context') + expect(body._adobeio).toHaveProperty('agent_name') + expect(['agent', 'human']).toContain(body._adobeio.invocation_context) + }) + + test('trackEvent sends agent context when CURSOR_AGENT env is set', async () => { + const orig = process.env.CURSOR_AGENT + process.env.CURSOR_AGENT = '1' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binTest') + await telemetryLib.trackEvent('postrun') + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body._adobeio.invocation_context).toBe('agent') + expect(body._adobeio.agent_name).toBe('cursor') + if (orig !== undefined) process.env.CURSOR_AGENT = orig + else delete process.env.CURSOR_AGENT + }) +}) + +describe('getInvocationContext', () => { + test('returns human when no agent env vars are set', () => { + const result = telemetryLib.getInvocationContext({}) + expect(result).toEqual({ isAgent: false, agentName: null }) + }) + + test('returns agent cursor when CURSOR_AGENT is set', () => { + const result = telemetryLib.getInvocationContext({ CURSOR_AGENT: '1' }) + expect(result).toEqual({ isAgent: true, agentName: 'cursor' }) + }) + + test('returns agent with name when AGENT is set to a value', () => { + const result = telemetryLib.getInvocationContext({ AGENT: 'goose' }) + expect(result).toEqual({ isAgent: true, agentName: 'goose' }) + }) + + test('returns agent generic when AGENT=1', () => { + const result = telemetryLib.getInvocationContext({ AGENT: '1' }) + expect(result).toEqual({ isAgent: true, agentName: 'generic' }) + }) + + test('returns aio-opt-in when AIO_AGENT is set', () => { + const result = telemetryLib.getInvocationContext({ AIO_AGENT: '1' }) + expect(result).toEqual({ isAgent: true, agentName: 'aio-opt-in' }) + }) + + test('returns aio-opt-in when AIO_INVOCATION_CONTEXT=agent', () => { + const result = telemetryLib.getInvocationContext({ AIO_INVOCATION_CONTEXT: 'agent' }) + expect(result).toEqual({ isAgent: true, agentName: 'aio-opt-in' }) + }) + + test('returns human when AIO_INVOCATION_CONTEXT is not agent', () => { + const result = telemetryLib.getInvocationContext({ AIO_INVOCATION_CONTEXT: 'human' }) + expect(result).toEqual({ isAgent: false, agentName: null }) + }) + + test('AGENT takes precedence over tool-specific when both set', () => { + const result = telemetryLib.getInvocationContext({ AGENT: 'goose', CURSOR_AGENT: '1' }) + expect(result).toEqual({ isAgent: true, agentName: 'goose' }) + }) + + test('ignores empty string env values', () => { + const result = telemetryLib.getInvocationContext({ CURSOR_AGENT: '' }) + expect(result).toEqual({ isAgent: false, agentName: null }) + }) }) From 95feec513673cb9582aa65800fd347e140af9cb6 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 6 Apr 2026 18:08:02 -0700 Subject: [PATCH 02/31] more post to worker to not delay command --- src/flush-worker.js | 46 ++++++++++++++++++ src/telemetry-lib.js | 98 ++++++++++++++++++++++++-------------- test/hooks.test.js | 61 +++++++++++++----------- test/telemetry-lib.test.js | 81 +++++++++++++++++++++++++++---- 4 files changed, 213 insertions(+), 73 deletions(-) create mode 100644 src/flush-worker.js diff --git a/src/flush-worker.js b/src/flush-worker.js new file mode 100644 index 0000000..c40ca70 --- /dev/null +++ b/src/flush-worker.js @@ -0,0 +1,46 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Telemetry flush worker — spawned as a detached subprocess by trackEvent so + * the parent process can exit immediately without waiting on the HTTP POST. + * + * Accepts a single CLI argument: a JSON-encoded object with shape { body: string }. + * The endpoint URL and auth headers are owned here so they never appear in + * process arguments (ps aux) or are passed across the IPC boundary. + */ + +'use strict' + +const { createFetch } = require('@adobe/aio-lib-core-networking') +const fetch = createFetch() + +const POST_URL = 'https://metric-api.newrelic.com/metric/v1' +const FETCH_HEADERS = { + 'Content-Type': 'application/json', + // New Relic ingest key — write-only, cannot read data or access any other system. + 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' +} + +async function main () { + try { + const { body } = JSON.parse(process.argv[2]) + await fetch(POST_URL, { + method: 'POST', + headers: FETCH_HEADERS, + body + }) + } catch (e) { + // silently ignore — telemetry errors should never surface to users + } +} + +main() diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index cc31193..be8f040 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -9,16 +9,36 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { createFetch } = require('@adobe/aio-lib-core-networking') +const { spawn } = require('child_process') +const path = require('path') const config = require('@adobe/aio-lib-core-config') -const fetch = createFetch() const osName = require('os-name') const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') let isDisabledForCommand = false +/** + * Detects GitHub Copilot Chat command shims injected into PATH. + * + * @param {string} pathValue - PATH environment variable value. + * @returns {string|null} Agent name when detected, otherwise null. + */ +function detectCopilotAgent (pathValue) { + pathValue = pathValue || '' + if (typeof pathValue !== 'string') { + return null + } + + if (pathValue.includes('github.copilot-chat/debugCommand') || pathValue.includes('github.copilot-chat/copilotCli')) { + return 'github-copilot' + } + + return null +} + +// TODO: detect VSCODE run as an agent /** * Environment variables checked for agent detection (proposed standard first, then tool-specific). * Used for metrics only. See aio-cli README "Agent detection" for full list. @@ -36,6 +56,7 @@ const AGENT_ENV_VARS = [ { env: 'AUGMENT_AGENT', name: () => 'augment' }, { env: 'CLINE_ACTIVE', name: () => 'cline' }, { env: 'OPENCODE_CLIENT', name: () => 'opencode' }, + { env: 'PATH', name: detectCopilotAgent }, { env: 'REPL_ID', name: () => 'replit' } ] @@ -44,7 +65,7 @@ const AGENT_ENV_VARS = [ * Used for metrics only. * * @param {object} [env] - Environment object to read (defaults to process.env when omitted). - * @returns {{ isAgent: boolean, agentName: string|null }} + * @returns {{ isAgent: boolean, agentName: string|null }} Invocation context metadata. */ function getInvocationContext (env) { const envToUse = env !== undefined ? env : process.env @@ -65,13 +86,11 @@ const osNameVersion = osName() // this is set by the init hook, ex. @adobe/aio-cli@8.2.0 let rootCliVersion = '?' let prerunEvent = { flags: [] } -// postUrl and fetchHeaders are set by the init hook if these values are set in the root cli package.json -let postUrl = 'https://dcs.adobedc.net/collection/ffb5bdcefe744485c5c968662012f91293eee10f5dac4ca009beb14d7c028424?asynchronous=true' + +let postUrl = 'https://metric-api.newrelic.com/metric/v1' let fetchHeaders = { 'Content-Type': 'application/json', - 'x-adobe-flow-id': '18dce8db-f523-4ff1-8806-0719de3fd367', - 'x-api-key': 'adobe_io', - 'sandbox-name': 'developer-lifecycle-dev1' + 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' } let configKey = 'aio-cli-telemetry' const defaultPrivacyPolicyLink = 'https://developer.adobe.com/app-builder/docs/guides/telemetry/' @@ -101,11 +120,11 @@ const getOffMessage = (binName) => { * @param {string} eventData additional data, like the error message, or custom telemetry payload * @returns {undefined} */ -async function trackEvent (eventType, eventData = '') { +async function trackEvent (eventType, eventData = {}) { // prerunEvent will be null when telemetry-prompt event fires, this happens before // any command is actually run, so we want to ignore the command+flags in this case - if (isDisabledForCommand || config.get(`${configKey}.optOut`, 'global') === true) { + if (isDisabledForCommand || process.env.AIO_TELEMETRY_DISABLED || config.get(`${configKey}.optOut`, 'global') === true) { debug('Telemetry is off. Not logging telemetry event', eventType) } else { const clientId = getClientId() @@ -114,32 +133,37 @@ async function trackEvent (eventType, eventData = '') { const fetchConfig = { method: 'POST', headers: fetchHeaders, - body: JSON.stringify({ - id: Math.floor(timestamp * Math.random()), - timestamp, - _adobeio: { - eventType, - eventData, - cliVersion: rootCliVersion, - clientId, - command: prerunEvent.command, - commandDuration: timestamp - prerunEvent.start, - commandFlags: prerunEvent.flags.toString(), - commandSuccess: eventType !== 'command-error', - nodeVersion: process.version, - osNameVersion, - invocation_context: invocationContext.isAgent ? 'agent' : 'human', - agent_name: invocationContext.agentName - } - }) - } - try { - debug('posting telemetry event', fetchConfig) - const response = await fetch(postUrl, fetchConfig) - debug('response.ok = ', response.ok) - } catch (exc) { - debug('error reaching telemetry server : ', exc) + body: JSON.stringify([{ + metrics: [{ + name: 'aio.cli.telemetry', + type: 'gauge', + value: 1, + // id: Math.floor(timestamp * Math.random()), + timestamp, + attributes: { + eventType, + eventData: JSON.stringify(eventData), + cliVersion: rootCliVersion, + clientId, + command: prerunEvent.command, + commandDuration: timestamp - prerunEvent.start, + commandFlags: prerunEvent.flags.toString(), + commandSuccess: eventType !== 'command-error', + nodeVersion: process.version, + osNameVersion, + invocation_context: invocationContext.isAgent ? 'agent' : 'human', + agent_name: invocationContext?.agentName || 'unknown' + } + }] + }]) } + const flushPayload = JSON.stringify({ body: fetchConfig.body }) + const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { + env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' }, + detached: true, + stdio: 'ignore' + }) + child.unref() } } @@ -173,13 +197,13 @@ module.exports = { config.set(`${configKey}.optOut`, true) }, isEnabled: () => { - return !isDisabledForCommand && config.get(`${configKey}.optOut`, 'global') === false + return !isDisabledForCommand && !process.env.AIO_TELEMETRY_DISABLED && config.get(`${configKey}.optOut`, 'global') === false }, disableForCommand: () => { isDisabledForCommand = true }, isNull: () => { - return config.get(`${configKey}.optOut`, 'global') === undefined + return !process.env.AIO_TELEMETRY_DISABLED && config.get(`${configKey}.optOut`, 'global') === undefined }, trackEvent, trackPrerun, diff --git a/test/hooks.test.js b/test/hooks.test.js index ff23cd6..87fced3 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -16,8 +16,12 @@ const config = require('@adobe/aio-lib-core-config') jest.mock('inquirer') jest.mock('@adobe/aio-lib-core-config') +jest.mock('child_process', () => ({ + spawn: jest.fn(() => ({ unref: jest.fn() })) +})) const fetch = createFetch() +const { spawn } = require('child_process') const mockPackageJson = { bin: { aio: '' }, @@ -31,24 +35,25 @@ const mockPackageJson = { describe('hook interfaces', () => { beforeEach(() => { fetch.mockReset() + spawn.mockClear() }) test('command-error', async () => { const hook = require('../src/hooks/command-error') expect(typeof hook).toBe('function') await hook({ message: 'msg' }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"command-error"') })) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"command-error"') }) test('command-not-found', async () => { const hook = require('../src/hooks/command-not-found') expect(typeof hook).toBe('function') await hook({ id: 'id' }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"command-not-found"') })) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"command-not-found"') }) /** @@ -64,9 +69,10 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: [] }) expect(inquirer.prompt).toHaveBeenCalled() - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"telemetry-prompt","eventData":"accepted"') })) - expect(fetch).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"telemetry-prompt"') + expect(flushPayload.body).toContain('accepted') process.env = preEnv }) @@ -79,7 +85,7 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'telemetry', config: { name: 'name', version: '0.0.1' }, argv: [] }) expect(inquirer.prompt).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() process.env = preEnv }) @@ -90,7 +96,7 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'telemetry', config: { name: 'name', version: '0.0.1' }, argv: [] }) expect(inquirer.prompt).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) test('init prompt - dont run when oclif is generating readme', async () => { @@ -100,7 +106,7 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'readme', config: { name: 'name', version: '0.0.1' }, argv: [] }) expect(inquirer.prompt).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) test('init prompt - dont run when oclif is generating readme and CI is off', async () => { @@ -112,7 +118,7 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'readme', config: { name: 'name', version: '0.0.1' }, argv: [] }) expect(inquirer.prompt).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() process.env = preEnv }) @@ -129,7 +135,7 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) expect(inquirer.prompt).not.toHaveBeenCalled() await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--verbose'] }) - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(inquirer.prompt).not.toHaveBeenCalled() process.env = preEnv }) @@ -147,9 +153,10 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--verbose'] }) expect(inquirer.prompt).toHaveBeenCalled() - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"telemetry-prompt","eventData":"declined"') })) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"telemetry-prompt"') + expect(flushPayload.body).toContain('declined') process.env = preEnv }) @@ -162,18 +169,18 @@ describe('hook interfaces', () => { .mockReturnValueOnce(false) await hook({ message: 'msg' }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"telemetry-custom-event"') })) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"telemetry-custom-event"') }) test('postrun', async () => { const hook = require('../src/hooks/postrun') expect(typeof hook).toBe('function') await hook({ Command: { id: 'id' }, argv: ['--hello'] }) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"_adobeio":{"eventType":"postrun"') })) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"eventType":"postrun"') }) /** @@ -187,16 +194,16 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--no-telemetry'] }) expect(inquirer.prompt).not.toHaveBeenCalled() - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) test('prerun', async () => { const hook = require('../src/hooks/prerun') expect(typeof hook).toBe('function') await hook({ Command: { id: 'id' }, argv: ['--hello'] }) - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() await hook({ Command: { id: 'id' }, argv: ['--hello', '--no-telemetry'] }) - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) test('prerun disables telemetry for postrun', async () => { @@ -205,6 +212,6 @@ describe('hook interfaces', () => { config.get.mockResolvedValue('clientidxyz') await preHook({ Command: { id: 'id' }, argv: ['--hello', '--no-telemetry'] }) await postHook({ Command: { id: 'id' }, argv: ['--hello', '--no-telemetry'] }) - expect(fetch).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) }) diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 60d8293..731ff4e 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -15,13 +15,18 @@ const telemetryLib = require('../src/telemetry-lib') const config = require('@adobe/aio-lib-core-config') jest.mock('@adobe/aio-lib-core-config') +jest.mock('child_process', () => ({ + spawn: jest.fn(() => ({ unref: jest.fn() })) +})) const fetch = createFetch() +const { spawn } = require('child_process') describe('telemetry-lib', () => { beforeEach(() => { jest.resetModules() fetch.mockReset() + spawn.mockClear() }) test('exports messages', async () => { @@ -48,18 +53,33 @@ describe('telemetry-lib', () => { await telemetryLib.trackEvent('test-event') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.clientId') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.optOut', 'global') - expect(fetch).toHaveBeenCalledWith(expect.any(String), - expect.objectContaining({ body: expect.stringContaining('"clientId":"clientidxyz"') })) + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.body).toContain('"clientId":"clientidxyz"') }) test('trackEvent includes invocation_context and agent_name in payload', async () => { config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binTest') await telemetryLib.trackEvent('postrun') - const body = JSON.parse(fetch.mock.calls[0][1].body) - expect(body._adobeio).toHaveProperty('invocation_context') - expect(body._adobeio).toHaveProperty('agent_name') - expect(['agent', 'human']).toContain(body._adobeio.invocation_context) + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + const attributes = body[0].metrics[0].attributes + expect(attributes).toHaveProperty('invocation_context') + expect(attributes).toHaveProperty('agent_name') + expect(['agent', 'human']).toContain(attributes.invocation_context) + }) + + test('trackEvent does not post when AIO_TELEMETRY_DISABLED is set', async () => { + const orig = process.env.AIO_TELEMETRY_DISABLED + process.env.AIO_TELEMETRY_DISABLED = '1' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binTest') + await telemetryLib.trackEvent('postrun') + expect(spawn).not.toHaveBeenCalled() + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED }) test('trackEvent sends agent context when CURSOR_AGENT env is set', async () => { @@ -68,9 +88,12 @@ describe('telemetry-lib', () => { config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binTest') await telemetryLib.trackEvent('postrun') - const body = JSON.parse(fetch.mock.calls[0][1].body) - expect(body._adobeio.invocation_context).toBe('agent') - expect(body._adobeio.agent_name).toBe('cursor') + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + const attributes = body[0].metrics[0].attributes + expect(attributes.invocation_context).toBe('agent') + expect(attributes.agent_name).toBe('cursor') if (orig !== undefined) process.env.CURSOR_AGENT = orig else delete process.env.CURSOR_AGENT }) @@ -112,6 +135,18 @@ describe('getInvocationContext', () => { expect(result).toEqual({ isAgent: false, agentName: null }) }) + test('returns github-copilot when Copilot Chat PATH markers are present', () => { + const result = telemetryLib.getInvocationContext({ + PATH: '/usr/local/bin:/Users/test/Library/Application Support/Code/User/globalStorage/github.copilot-chat/debugCommand:/Users/test/Library/Application Support/Code/User/globalStorage/github.copilot-chat/copilotCli' + }) + expect(result).toEqual({ isAgent: true, agentName: 'github-copilot' }) + }) + + test('returns human when PATH does not contain Copilot Chat markers', () => { + const result = telemetryLib.getInvocationContext({ PATH: '/usr/local/bin:/usr/bin:/bin' }) + expect(result).toEqual({ isAgent: false, agentName: null }) + }) + test('AGENT takes precedence over tool-specific when both set', () => { const result = telemetryLib.getInvocationContext({ AGENT: 'goose', CURSOR_AGENT: '1' }) expect(result).toEqual({ isAgent: true, agentName: 'goose' }) @@ -122,3 +157,31 @@ describe('getInvocationContext', () => { expect(result).toEqual({ isAgent: false, agentName: null }) }) }) + +describe('AIO_TELEMETRY_DISABLED', () => { + let orig + + beforeEach(() => { + orig = process.env.AIO_TELEMETRY_DISABLED + process.env.AIO_TELEMETRY_DISABLED = '1' + telemetryLib.init('a@4', 'binTest') + }) + + afterEach(() => { + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED + }) + + test('isEnabled returns false when AIO_TELEMETRY_DISABLED is set', () => { + // config.get would return false (opted in), but the env var should override + const config = require('@adobe/aio-lib-core-config') + config.get.mockReturnValue(false) + expect(telemetryLib.isEnabled()).toBe(false) + }) + + test('isNull returns false when AIO_TELEMETRY_DISABLED is set', () => { + const config = require('@adobe/aio-lib-core-config') + config.get.mockReturnValue(undefined) + expect(telemetryLib.isNull()).toBe(false) + }) +}) From aca78ed963f1089cff0ba35cd3c40c04afa13e70 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 6 Apr 2026 18:57:16 -0700 Subject: [PATCH 03/31] feat(telemetry): fire-and-forget flush, retry queue --- README.md | 81 ++++++++++++++++++++++------- src/flush-worker.js | 42 ++++++++++++--- src/queue-store.js | 81 +++++++++++++++++++++++++++++ src/telemetry-lib.js | 14 ++--- test/flush-worker.test.js | 104 +++++++++++++++++++++++++++++++++++++ test/queue-store.test.js | 90 ++++++++++++++++++++++++++++++++ test/telemetry-lib.test.js | 47 +++++++++++++++++ 7 files changed, 424 insertions(+), 35 deletions(-) create mode 100644 src/queue-store.js create mode 100644 test/flush-worker.test.js create mode 100644 test/queue-store.test.js diff --git a/README.md b/README.md index 4f6a281..a0c110f 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ _See code: [src/commands/telemetry/index.js](https://github.com/adobe/aio-cli-pl ## Configuration The following values need to be set when this plugin is hosted by different CLIs - `aioTelemetry`: defined object in root cli package.json with values: - - `postUrl` : Where to post telemetry data - - `postHeaders`: Any specific headers that need to be posted with telemetry data (ex. x-api-key) + - `postUrl` : Where to post telemetry data (overrides the default New Relic ingest endpoint) + - `fetchHeaders`: Headers to send with the telemetry POST request (overrides the default New Relic ingest headers) - `productPrivacyPolicyLink`: A link to display to users when prompting to optIn - `productName`: How to refer to the cli when user is prompted to enable telemetry - this value is read from `displayName` or `name` of the cli's package.json @@ -48,22 +48,67 @@ The following values need to be set when this plugin is hosted by different CLIs - ex. To turn telemetry on run `${productBin} telemetry on` - this value is read from 'bin' of the cli's package.json, if the package exports more than 1 bin the first is used -## POST data +## Opting out via environment variable + +Set `AIO_TELEMETRY_DISABLED=1` (or any truthy value) to suppress all telemetry without modifying the persisted opt-in state. Useful for CI pipelines and scripted environments. -Here is an example of the event data as posted: +```sh +AIO_TELEMETRY_DISABLED=1 aio app deploy ``` -{ "id": 656915165813, - "timestamp": 1673404918437, - "_adobeio": { - "eventType": "telemetry-prompt", - "eventData": "accepted", - "cliVersion": "@adobe/aio-cli@9.1.1", - "clientId": 264421030538, - "commandDuration": 5661, - "commandFlags": "", - "commandSuccess": true, - "nodeVersion": "v14.20.0", - "osNameVersion": "macOS" - } -} + +## Flush architecture + +Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI exits at its normal time without waiting for the HTTP POST to complete. The worker owns the ingest endpoint and credentials. + +## Agent detection + +The plugin detects whether the CLI is being invoked by an AI agent or a human by inspecting environment variables at the time of the event. The detected context is included in every event as `invocation_context` (`"agent"` or `"human"`) and `agent_name`. + +Supported agents detected automatically: + +| Environment variable | Detected agent name | +|---|---| +| `AGENT` | value of the variable (or `"generic"` if `1`/`true`) | +| `AI_AGENT` | value of the variable (or `"generic"` if `1`/`true`) | +| `AIO_AGENT` | `aio-opt-in` | +| `AIO_INVOCATION_CONTEXT=agent` | `aio-opt-in` | +| `CURSOR_AGENT` | `cursor` | +| `CLAUDECODE` / `CLAUDE_CODE` | `claude` | +| `GEMINI_CLI` | `gemini` | +| `CODEX_SANDBOX` | `codex` | +| `AUGMENT_AGENT` | `augment` | +| `CLINE_ACTIVE` | `cline` | +| `OPENCODE_CLIENT` | `opencode` | +| `REPL_ID` | `replit` | +| `PATH` containing `github.copilot-chat` | `github-copilot` | + +To opt into agent tracking without setting a tool-specific variable, set `AIO_INVOCATION_CONTEXT=agent`. + +## POST data + +Events are posted to the New Relic Metric API. Here is an example of the payload: + +```json +[{ + "metrics": [{ + "name": "aio.cli.telemetry", + "type": "gauge", + "value": 1, + "timestamp": 1673404918437, + "attributes": { + "eventType": "postrun", + "eventData": "{}", + "cliVersion": "@adobe/aio-cli@11.0.2", + "clientId": 264421030538, + "command": "app:deploy", + "commandDuration": 5661, + "commandFlags": "", + "commandSuccess": true, + "nodeVersion": "v22.21.1", + "osNameVersion": "macOS Sequoia 15.4", + "invocation_context": "human", + "agent_name": "unknown" + } + }] +}] ``` diff --git a/src/flush-worker.js b/src/flush-worker.js index c40ca70..70ad376 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -13,14 +13,20 @@ governing permissions and limitations under the License. * Telemetry flush worker — spawned as a detached subprocess by trackEvent so * the parent process can exit immediately without waiting on the HTTP POST. * - * Accepts a single CLI argument: a JSON-encoded object with shape { body: string }. - * The endpoint URL and auth headers are owned here so they never appear in - * process arguments (ps aux) or are passed across the IPC boundary. + * Accepts a single CLI argument: a JSON-encoded object with shape { body: string } + * where body is a serialised New Relic metric payload (array of metric batches). + * + * On each run the worker merges any previously-failed events from the persistent + * queue (src/queue-store.js) with the current event before POSTing. On success + * the queue is cleared; on failure the merged set is written back so the next + * invocation can retry. */ 'use strict' const { createFetch } = require('@adobe/aio-lib-core-networking') +const { readQueue, writeQueue, clearQueue } = require('./queue-store') + const fetch = createFetch() const POST_URL = 'https://metric-api.newrelic.com/metric/v1' @@ -31,16 +37,38 @@ const FETCH_HEADERS = { } async function main () { + // Parse the current event payload passed by the parent process. + let currentMetrics try { const { body } = JSON.parse(process.argv[2]) + currentMetrics = JSON.parse(body)[0].metrics + } catch { + // Malformed argument — nothing useful to do. + return + } + + // Merge previously-queued metrics (if any) with the current event so they + // are all retried in a single POST. + const queuedMetrics = readQueue() + const allMetrics = [...queuedMetrics, ...currentMetrics] + + try { await fetch(POST_URL, { method: 'POST', headers: FETCH_HEADERS, - body + body: JSON.stringify([{ metrics: allMetrics }]) }) - } catch (e) { - // silently ignore — telemetry errors should never surface to users + // Successful delivery — the queue is no longer needed. + clearQueue() + } catch { + // Network failure — persist all metrics so the next invocation can retry. + writeQueue(allMetrics) } } -main() +/* istanbul ignore next */ +if (require.main === module) { + main() +} + +module.exports = { main } diff --git a/src/queue-store.js b/src/queue-store.js new file mode 100644 index 0000000..60fca54 --- /dev/null +++ b/src/queue-store.js @@ -0,0 +1,81 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Persistent queue store for telemetry events that failed to POST. + * + * The queue is kept in a dedicated JSON file that lives alongside the main aio + * config directory but is completely separate from user-visible aio configuration: + * + * ${XDG_CONFIG_HOME:-~/.config}/aio/.telemetry-queue.json + * + * On the next CLI invocation the flush worker picks up any queued events, merges + * them with the new event, and retries the batch. On success the file is removed. + */ + +'use strict' + +const fs = require('fs') +const path = require('path') +const os = require('os') + +/** + * Resolves the absolute path to the queue file, honouring XDG_CONFIG_HOME when set. + * @returns {string} + */ +function getQueuePath () { + const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') + return path.join(base, 'aio', '.telemetry-queue.json') +} + +/** + * Reads the current queue from disk. + * Returns an empty array when the file does not exist or is unreadable. + * @returns {Array} Flat array of New Relic metric objects. + */ +function readQueue () { + try { + const data = fs.readFileSync(getQueuePath(), 'utf8') + const parsed = JSON.parse(data) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +/** + * Persists the given metrics array to the queue file, creating directories as needed. + * Silently ignores write errors — telemetry must never crash the CLI. + * @param {Array} items Flat array of New Relic metric objects. + */ +function writeQueue (items) { + const file = getQueuePath() + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(items), 'utf8') + } catch { + // silently ignore — failing to persist the queue must not affect the CLI + } +} + +/** + * Removes the queue file. + * Silently ignores errors (e.g. the file does not exist). + */ +function clearQueue () { + try { + fs.unlinkSync(getQueuePath()) + } catch { + // silently ignore — queue file may not exist + } +} + +module.exports = { getQueuePath, readQueue, writeQueue, clearQueue } diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index be8f040..98888d0 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -26,15 +26,9 @@ let isDisabledForCommand = false * @returns {string|null} Agent name when detected, otherwise null. */ function detectCopilotAgent (pathValue) { - pathValue = pathValue || '' - if (typeof pathValue !== 'string') { - return null - } - if (pathValue.includes('github.copilot-chat/debugCommand') || pathValue.includes('github.copilot-chat/copilotCli')) { return 'github-copilot' } - return null } @@ -72,7 +66,7 @@ function getInvocationContext (env) { for (const { env: key, name } of AGENT_ENV_VARS) { const value = envToUse[key] if (value !== undefined && value !== '') { - const agentName = typeof name === 'function' ? name(value) : name + const agentName = name(value) if (agentName) { return { isAgent: true, agentName } } @@ -83,7 +77,7 @@ function getInvocationContext (env) { const osNameVersion = osName() -// this is set by the init hook, ex. @adobe/aio-cli@8.2.0 +// this is set by the init hook, ex. @adobe/aio-cli@8.2.0q let rootCliVersion = '?' let prerunEvent = { flags: [] } @@ -151,8 +145,8 @@ async function trackEvent (eventType, eventData = {}) { commandSuccess: eventType !== 'command-error', nodeVersion: process.version, osNameVersion, - invocation_context: invocationContext.isAgent ? 'agent' : 'human', - agent_name: invocationContext?.agentName || 'unknown' + invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human', + agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown' } }] }]) diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js new file mode 100644 index 0000000..f23320b --- /dev/null +++ b/test/flush-worker.test.js @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Adobe Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const { createFetch } = require('@adobe/aio-lib-core-networking') + +jest.mock('../src/queue-store', () => ({ + readQueue: jest.fn(() => []), + writeQueue: jest.fn(), + clearQueue: jest.fn() +})) + +const fetch = createFetch() +const { readQueue, writeQueue, clearQueue } = require('../src/queue-store') +const { main } = require('../src/flush-worker') + +const METRIC = { name: 'aio.cli.telemetry', type: 'gauge', value: 1, timestamp: 1000, attributes: { eventType: 'postrun' } } +const BODY = JSON.stringify([{ metrics: [METRIC] }]) + +describe('flush-worker main()', () => { + let origArgv + + beforeEach(() => { + origArgv = process.argv + fetch.mockReset() + readQueue.mockClear() + writeQueue.mockClear() + clearQueue.mockClear() + }) + + afterEach(() => { + process.argv = origArgv + }) + + test('POSTs merged metrics and clears queue on success', async () => { + const queued = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'prerun' } }] + readQueue.mockReturnValue(queued) + fetch.mockResolvedValue({ ok: true }) + + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + await main() + + expect(fetch).toHaveBeenCalledTimes(1) + const [url, opts] = fetch.mock.calls[0] + expect(url).toBe('https://metric-api.newrelic.com/metric/v1') + expect(opts.method).toBe('POST') + expect(opts.headers['Api-Key']).toBeTruthy() + + const posted = JSON.parse(opts.body) + expect(posted[0].metrics).toHaveLength(2) + expect(posted[0].metrics[0]).toEqual(queued[0]) + expect(posted[0].metrics[1]).toEqual(METRIC) + + expect(clearQueue).toHaveBeenCalledTimes(1) + expect(writeQueue).not.toHaveBeenCalled() + }) + + test('writes merged metrics to queue on fetch failure', async () => { + readQueue.mockReturnValue([]) + fetch.mockRejectedValue(new Error('network error')) + + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + await main() + + expect(writeQueue).toHaveBeenCalledTimes(1) + expect(writeQueue).toHaveBeenCalledWith([METRIC]) + expect(clearQueue).not.toHaveBeenCalled() + }) + + test('merges empty queue with current event', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue({ ok: true }) + + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + await main() + + const posted = JSON.parse(fetch.mock.calls[0][1].body) + expect(posted[0].metrics).toHaveLength(1) + expect(posted[0].metrics[0]).toEqual(METRIC) + expect(clearQueue).toHaveBeenCalledTimes(1) + }) + + test('returns silently when argv[2] is missing', async () => { + process.argv = ['node', 'flush-worker.js'] + await main() + expect(fetch).not.toHaveBeenCalled() + expect(writeQueue).not.toHaveBeenCalled() + }) + + test('returns silently when argv[2] is malformed JSON', async () => { + process.argv = ['node', 'flush-worker.js', 'not-json{{{'] + await main() + expect(fetch).not.toHaveBeenCalled() + expect(writeQueue).not.toHaveBeenCalled() + }) +}) diff --git a/test/queue-store.test.js b/test/queue-store.test.js new file mode 100644 index 0000000..f73a22e --- /dev/null +++ b/test/queue-store.test.js @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Adobe Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const path = require('path') +const os = require('os') + +jest.mock('fs') +const fs = require('fs') + +const queueStore = require('../src/queue-store') + +const DEFAULT_QUEUE_PATH = path.join(os.homedir(), '.config', 'aio', '.telemetry-queue.json') +const XDG_QUEUE_PATH = path.join('/xdg-home', 'aio', '.telemetry-queue.json') + +describe('queue-store', () => { + beforeEach(() => { + jest.resetAllMocks() + delete process.env.XDG_CONFIG_HOME + }) + + describe('getQueuePath', () => { + test('returns path under ~/.config/aio when XDG_CONFIG_HOME is not set', () => { + expect(queueStore.getQueuePath()).toBe(DEFAULT_QUEUE_PATH) + }) + + test('honours XDG_CONFIG_HOME when set', () => { + process.env.XDG_CONFIG_HOME = '/xdg-home' + expect(queueStore.getQueuePath()).toBe(XDG_QUEUE_PATH) + delete process.env.XDG_CONFIG_HOME + }) + }) + + describe('readQueue', () => { + test('returns parsed array when file contains valid JSON', () => { + const items = [{ name: 'aio.cli.telemetry', value: 1 }] + fs.readFileSync.mockReturnValue(JSON.stringify(items)) + expect(queueStore.readQueue()).toEqual(items) + }) + + test('returns empty array when file does not exist', () => { + fs.readFileSync.mockImplementation(() => { throw new Error('ENOENT') }) + expect(queueStore.readQueue()).toEqual([]) + }) + + test('returns empty array when file contains invalid JSON', () => { + fs.readFileSync.mockReturnValue('not-json{{{') + expect(queueStore.readQueue()).toEqual([]) + }) + + test('returns empty array when parsed value is not an array', () => { + fs.readFileSync.mockReturnValue(JSON.stringify({ foo: 'bar' })) + expect(queueStore.readQueue()).toEqual([]) + }) + }) + + describe('writeQueue', () => { + test('creates directory and writes items as JSON', () => { + const items = [{ name: 'aio.cli.telemetry', value: 1 }] + queueStore.writeQueue(items) + expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(DEFAULT_QUEUE_PATH), { recursive: true }) + expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify(items), 'utf8') + }) + + test('silently ignores write errors', () => { + fs.mkdirSync.mockImplementation(() => { throw new Error('EACCES') }) + expect(() => queueStore.writeQueue([])).not.toThrow() + }) + }) + + describe('clearQueue', () => { + test('deletes the queue file', () => { + queueStore.clearQueue() + expect(fs.unlinkSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH) + }) + + test('silently ignores errors when file does not exist', () => { + fs.unlinkSync.mockImplementation(() => { throw new Error('ENOENT') }) + expect(() => queueStore.clearQueue()).not.toThrow() + }) + }) +}) diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 731ff4e..2c11e50 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -156,6 +156,53 @@ describe('getInvocationContext', () => { const result = telemetryLib.getInvocationContext({ CURSOR_AGENT: '' }) expect(result).toEqual({ isAgent: false, agentName: null }) }) + + test('returns agent generic when AGENT=true', () => { + const result = telemetryLib.getInvocationContext({ AGENT: 'true' }) + expect(result).toEqual({ isAgent: true, agentName: 'generic' }) + }) + + test('returns aio-opt-in when AI_AGENT is set', () => { + const result = telemetryLib.getInvocationContext({ AI_AGENT: 'my-agent' }) + expect(result).toEqual({ isAgent: true, agentName: 'my-agent' }) + }) + + test('returns generic when AI_AGENT=1', () => { + const result = telemetryLib.getInvocationContext({ AI_AGENT: '1' }) + expect(result).toEqual({ isAgent: true, agentName: 'generic' }) + }) + + test('returns claude when CLAUDECODE is set', () => { + expect(telemetryLib.getInvocationContext({ CLAUDECODE: '1' })).toEqual({ isAgent: true, agentName: 'claude' }) + }) + + test('returns claude when CLAUDE_CODE is set', () => { + expect(telemetryLib.getInvocationContext({ CLAUDE_CODE: '1' })).toEqual({ isAgent: true, agentName: 'claude' }) + }) + + test('returns gemini when GEMINI_CLI is set', () => { + expect(telemetryLib.getInvocationContext({ GEMINI_CLI: '1' })).toEqual({ isAgent: true, agentName: 'gemini' }) + }) + + test('returns codex when CODEX_SANDBOX is set', () => { + expect(telemetryLib.getInvocationContext({ CODEX_SANDBOX: '1' })).toEqual({ isAgent: true, agentName: 'codex' }) + }) + + test('returns augment when AUGMENT_AGENT is set', () => { + expect(telemetryLib.getInvocationContext({ AUGMENT_AGENT: '1' })).toEqual({ isAgent: true, agentName: 'augment' }) + }) + + test('returns cline when CLINE_ACTIVE is set', () => { + expect(telemetryLib.getInvocationContext({ CLINE_ACTIVE: '1' })).toEqual({ isAgent: true, agentName: 'cline' }) + }) + + test('returns opencode when OPENCODE_CLIENT is set', () => { + expect(telemetryLib.getInvocationContext({ OPENCODE_CLIENT: '1' })).toEqual({ isAgent: true, agentName: 'opencode' }) + }) + + test('returns replit when REPL_ID is set', () => { + expect(telemetryLib.getInvocationContext({ REPL_ID: 'abc123' })).toEqual({ isAgent: true, agentName: 'replit' }) + }) }) describe('AIO_TELEMETRY_DISABLED', () => { From 66106d58da8f12af6f457e44e61bb80ebe656736 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 6 Apr 2026 19:48:51 -0700 Subject: [PATCH 04/31] fix linting/jsdoc --- src/flush-worker.js | 5 +++++ src/queue-store.js | 2 +- src/telemetry-lib.js | 4 ---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index 70ad376..06de68f 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -36,6 +36,11 @@ const FETCH_HEADERS = { 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' } +/** + * Reads the persistent queue, merges it with the current event, POSTs the batch, + * and either clears the queue on success or writes back on failure for retry. + * @returns {Promise} + */ async function main () { // Parse the current event payload passed by the parent process. let currentMetrics diff --git a/src/queue-store.js b/src/queue-store.js index 60fca54..fdf9310 100644 --- a/src/queue-store.js +++ b/src/queue-store.js @@ -29,7 +29,7 @@ const os = require('os') /** * Resolves the absolute path to the queue file, honouring XDG_CONFIG_HOME when set. - * @returns {string} + * @returns {string} Absolute path to .telemetry-queue.json. */ function getQueuePath () { const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 98888d0..faa9c88 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -81,7 +81,6 @@ const osNameVersion = osName() let rootCliVersion = '?' let prerunEvent = { flags: [] } -let postUrl = 'https://metric-api.newrelic.com/metric/v1' let fetchHeaders = { 'Content-Type': 'application/json', 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' @@ -178,9 +177,6 @@ module.exports = { if (remoteConf.fetchHeaders) { fetchHeaders = remoteConf.fetchHeaders } - if (remoteConf.postUrl) { - postUrl = remoteConf.postUrl - } configKey = binName + '-cli-telemetry' }, getClientId, From 392fa159426b542446b96ac6ce5670dd5b5af377 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 6 Apr 2026 20:05:17 -0700 Subject: [PATCH 05/31] fix: windows osname issues, removed --- package.json | 4 +--- src/telemetry-lib.js | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 24c3126..b6c1da1 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,7 @@ "@oclif/core": "^1.3.4", "ci-info": "^4.0.0", "debug": "^4.1.1", - "inquirer": "^8.2.1", - "os-name": "^4.0.1", - "splunk-logging": "^0.11.1" + "inquirer": "^8.2.1" }, "devDependencies": { "@adobe/eslint-config-aio-lib-config": "^4.0.0", diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index faa9c88..27d6141 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -11,9 +11,9 @@ governing permissions and limitations under the License. const { spawn } = require('child_process') const path = require('path') +const os = require('os') const config = require('@adobe/aio-lib-core-config') -const osName = require('os-name') const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') @@ -75,7 +75,7 @@ function getInvocationContext (env) { return { isAgent: false, agentName: null } } -const osNameVersion = osName() +const osNameVersion = `${os.type()} ${os.release()}` // this is set by the init hook, ex. @adobe/aio-cli@8.2.0q let rootCliVersion = '?' From 0b274ea82e7b57f45eceed5b3375e5c5f1eaf9be Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 8 May 2026 17:52:31 -0700 Subject: [PATCH 06/31] post to a proxy --- README.md | 22 ++++---- src/flush-worker.js | 34 +++++++++---- src/telemetry-lib.js | 75 ++++++++++++++++++++++++--- test/flush-worker.test.js | 39 ++++++++++---- test/hooks.test.js | 14 +++-- test/telemetry-lib.test.js | 102 ++++++++++++++++++++++++++++++++++--- 6 files changed, 237 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index a0c110f..57fa82d 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,14 @@ _See code: [src/commands/telemetry/index.js](https://github.com/adobe/aio-cli-pl ## Configuration -The following values need to be set when this plugin is hosted by different CLIs -- `aioTelemetry`: defined object in root cli package.json with values: - - `postUrl` : Where to post telemetry data (overrides the default New Relic ingest endpoint) - - `fetchHeaders`: Headers to send with the telemetry POST request (overrides the default New Relic ingest headers) - - `productPrivacyPolicyLink`: A link to display to users when prompting to optIn -- `productName`: How to refer to the cli when user is prompted to enable telemetry - - this value is read from `displayName` or `name` of the cli's package.json -- `productBin`: Output in help text - - ex. To turn telemetry on run `${productBin} telemetry on` - - this value is read from 'bin' of the cli's package.json, if the package exports more than 1 bin the first is used + +When this plugin is hosted by different CLIs: + +- `aioTelemetry` (optional object in the root `package.json`): + - `fetchHeaders`: Optional extra headers merged into telemetry requests (`Content-Type` is always set) + - `productPrivacyPolicyLink`: A link to display to users when prompting to opt in +- `productName`: How to refer to the CLI when the user is prompted to enable telemetry (from `displayName` or `name` in `package.json`) +- `productBin`: Shown in help text (from `bin` in `package.json`; if several bins exist, the first is used). Example: run `${productBin} telemetry on` ## Opting out via environment variable @@ -58,7 +56,7 @@ AIO_TELEMETRY_DISABLED=1 aio app deploy ## Flush architecture -Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI exits at its normal time without waiting for the HTTP POST to complete. The worker owns the ingest endpoint and credentials. +Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI can exit without waiting for the HTTP request to finish. ## Agent detection @@ -86,7 +84,7 @@ To opt into agent tracking without setting a tool-specific variable, set `AIO_IN ## POST data -Events are posted to the New Relic Metric API. Here is an example of the payload: +Example shape of the metric payload: ```json [{ diff --git a/src/flush-worker.js b/src/flush-worker.js index 06de68f..2fbae60 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -13,8 +13,9 @@ governing permissions and limitations under the License. * Telemetry flush worker — spawned as a detached subprocess by trackEvent so * the parent process can exit immediately without waiting on the HTTP POST. * - * Accepts a single CLI argument: a JSON-encoded object with shape { body: string } - * where body is a serialised New Relic metric payload (array of metric batches). + * Accepts a single CLI argument: a JSON-encoded object with shape + * { body: string, postUrl: string, headers?: object } where body is a serialised + * New Relic metric payload (array of metric batches) and postUrl is the App Builder proxy. * * On each run the worker merges any previously-failed events from the persistent * queue (src/queue-store.js) with the current event before POSTing. On success @@ -24,16 +25,15 @@ governing permissions and limitations under the License. 'use strict' +const debug = require('debug')('aio-telemetry:flush-worker') const { createFetch } = require('@adobe/aio-lib-core-networking') const { readQueue, writeQueue, clearQueue } = require('./queue-store') const fetch = createFetch() -const POST_URL = 'https://metric-api.newrelic.com/metric/v1' -const FETCH_HEADERS = { +const DEFAULT_HEADERS = { 'Content-Type': 'application/json', - // New Relic ingest key — write-only, cannot read data or access any other system. - 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' + 'x-ow-extra-logging': 'on' } /** @@ -44,8 +44,21 @@ const FETCH_HEADERS = { async function main () { // Parse the current event payload passed by the parent process. let currentMetrics + let postUrl + let requestHeaders = { ...DEFAULT_HEADERS } try { - const { body } = JSON.parse(process.argv[2]) + const parsed = JSON.parse(process.argv[2]) + const { body, postUrl: url, headers } = parsed + if (!url || typeof url !== 'string') { + return + } + postUrl = url + if (headers && typeof headers === 'object') { + const safe = { ...headers } + delete safe['Api-Key'] + delete safe['api-key'] + requestHeaders = { ...DEFAULT_HEADERS, ...safe } + } currentMetrics = JSON.parse(body)[0].metrics } catch { // Malformed argument — nothing useful to do. @@ -58,10 +71,11 @@ async function main () { const allMetrics = [...queuedMetrics, ...currentMetrics] try { - await fetch(POST_URL, { + debug('POST %s requestHeaders=%o', postUrl, requestHeaders) + await fetch(postUrl, { method: 'POST', - headers: FETCH_HEADERS, - body: JSON.stringify([{ metrics: allMetrics }]) + headers: requestHeaders, + body: JSON.stringify({ batches: [{ metrics: allMetrics }] }) }) // Successful delivery — the queue is no longer needed. clearQueue() diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 27d6141..aa61ad5 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -17,6 +17,9 @@ const config = require('@adobe/aio-lib-core-config') const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') +/** Adobe I/O App Builder web action that forwards CLI metrics to New Relic (ingest key stays server-side). */ +const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy-stage.adobeio-static.net/api/v1/web/dx-excshell-1/telemetry' + let isDisabledForCommand = false /** @@ -81,11 +84,28 @@ const osNameVersion = `${os.type()} ${os.release()}` let rootCliVersion = '?' let prerunEvent = { flags: [] } +/** @type {Record} Headers for the telemetry proxy POST (never include New Relic keys). */ let fetchHeaders = { - 'Content-Type': 'application/json', - 'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL' + 'Content-Type': 'application/json' } +/** @type {string} Resolved proxy URL (defaults at module load; init may override). */ +let postUrl = DEFAULT_TELEMETRY_POST_URL let configKey = 'aio-cli-telemetry' + +/** + * Strips New Relic credential header names from a header map (CLI must not send ingest keys). + * @param {Record|null|undefined} headers - optional header map from package.json aioTelemetry + * @returns {Record} copy without Api-Key fields + */ +function withoutNrKeys (headers) { + if (!headers || typeof headers !== 'object') { + return {} + } + const out = { ...headers } + delete out['Api-Key'] + delete out['api-key'] + return out +} const defaultPrivacyPolicyLink = 'https://developer.adobe.com/app-builder/docs/guides/telemetry/' /** @@ -107,17 +127,48 @@ const getOffMessage = (binName) => { return `\nTelemetry is off.\nIf you would like to turn telemetry on, simply run \`${binName} telemetry on\`` } +/** + * Builds the value stored in the metric `eventData` attribute. For `postrun`, an empty object is + * replaced with `{ durationMs }` from `global.prerunTimer` so older CLIs / hooks that omit the + * second argument still send timing. + * + * @param {string} eventType - telemetry event name (e.g. postrun, command-error) + * @param {object|string|number|undefined} raw - argument passed to trackEvent + * @returns {object|string|number} payload serialized into the metric attribute + */ +function resolveEventData (eventType, raw) { + if (eventType !== 'postrun') { + return raw === undefined ? {} : raw + } + const empty = raw === undefined || raw === null || + (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0) + if (!empty) { + return raw + } + if (typeof global.prerunTimer === 'number') { + return { durationMs: Date.now() - global.prerunTimer } + } + return {} +} + /** * @description tracks the event * @param {string} eventType prerun, postrun, command-error, command-not-found, telemetry - * @param {string} eventData additional data, like the error message, or custom telemetry payload + * @param {object|string|number|undefined} [rawEventData] Optional hook payload (e.g. `{ message }` on errors). + * Command/flags/duration are also sent as separate metric attributes from prerun/postrun. * @returns {undefined} */ -async function trackEvent (eventType, eventData = {}) { +async function trackEvent (eventType, rawEventData = {}) { // prerunEvent will be null when telemetry-prompt event fires, this happens before // any command is actually run, so we want to ignore the command+flags in this case - if (isDisabledForCommand || process.env.AIO_TELEMETRY_DISABLED || config.get(`${configKey}.optOut`, 'global') === true) { + const eventData = resolveEventData(eventType, rawEventData) + + const optedOut = isDisabledForCommand || process.env.AIO_TELEMETRY_DISABLED || config.get(`${configKey}.optOut`, 'global') === true + const willSend = !optedOut + debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend) + + if (optedOut) { debug('Telemetry is off. Not logging telemetry event', eventType) } else { const clientId = getClientId() @@ -150,7 +201,11 @@ async function trackEvent (eventType, eventData = {}) { }] }]) } - const flushPayload = JSON.stringify({ body: fetchConfig.body }) + const flushPayload = JSON.stringify({ + body: fetchConfig.body, + postUrl, + headers: fetchHeaders + }) const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' }, detached: true, @@ -174,8 +229,10 @@ module.exports = { init: (versionString, binName, remoteConf = {}) => { global.commandHookStartTime = Date.now() rootCliVersion = versionString - if (remoteConf.fetchHeaders) { - fetchHeaders = remoteConf.fetchHeaders + postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL + fetchHeaders = { + 'Content-Type': 'application/json', + ...withoutNrKeys(remoteConf.fetchHeaders) } configKey = binName + '-cli-telemetry' }, @@ -198,6 +255,8 @@ module.exports = { trackEvent, trackPrerun, // secret api for testing + DEFAULT_TELEMETRY_POST_URL, + resolveEventData, reset: () => { config.delete(configKey) }, diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js index f23320b..276269b 100644 --- a/test/flush-worker.test.js +++ b/test/flush-worker.test.js @@ -22,8 +22,10 @@ const fetch = createFetch() const { readQueue, writeQueue, clearQueue } = require('../src/queue-store') const { main } = require('../src/flush-worker') +const PROXY = 'https://telemetry-proxy.example/api/v1/web/dx-excshell-1/telemetry' const METRIC = { name: 'aio.cli.telemetry', type: 'gauge', value: 1, timestamp: 1000, attributes: { eventType: 'postrun' } } const BODY = JSON.stringify([{ metrics: [METRIC] }]) +const flushArg = (body = BODY) => JSON.stringify({ body, postUrl: PROXY, headers: { 'Content-Type': 'application/json' } }) describe('flush-worker main()', () => { let origArgv @@ -45,19 +47,20 @@ describe('flush-worker main()', () => { readQueue.mockReturnValue(queued) fetch.mockResolvedValue({ ok: true }) - process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + process.argv = ['node', 'flush-worker.js', flushArg()] await main() expect(fetch).toHaveBeenCalledTimes(1) const [url, opts] = fetch.mock.calls[0] - expect(url).toBe('https://metric-api.newrelic.com/metric/v1') + expect(url).toBe(PROXY) expect(opts.method).toBe('POST') - expect(opts.headers['Api-Key']).toBeTruthy() + expect(opts.headers['Content-Type']).toBe('application/json') + expect(opts.headers['Api-Key']).toBeUndefined() const posted = JSON.parse(opts.body) - expect(posted[0].metrics).toHaveLength(2) - expect(posted[0].metrics[0]).toEqual(queued[0]) - expect(posted[0].metrics[1]).toEqual(METRIC) + expect(posted.batches[0].metrics).toHaveLength(2) + expect(posted.batches[0].metrics[0]).toEqual(queued[0]) + expect(posted.batches[0].metrics[1]).toEqual(METRIC) expect(clearQueue).toHaveBeenCalledTimes(1) expect(writeQueue).not.toHaveBeenCalled() @@ -67,7 +70,7 @@ describe('flush-worker main()', () => { readQueue.mockReturnValue([]) fetch.mockRejectedValue(new Error('network error')) - process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + process.argv = ['node', 'flush-worker.js', flushArg()] await main() expect(writeQueue).toHaveBeenCalledTimes(1) @@ -79,12 +82,12 @@ describe('flush-worker main()', () => { readQueue.mockReturnValue([]) fetch.mockResolvedValue({ ok: true }) - process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + process.argv = ['node', 'flush-worker.js', flushArg()] await main() const posted = JSON.parse(fetch.mock.calls[0][1].body) - expect(posted[0].metrics).toHaveLength(1) - expect(posted[0].metrics[0]).toEqual(METRIC) + expect(posted.batches[0].metrics).toHaveLength(1) + expect(posted.batches[0].metrics[0]).toEqual(METRIC) expect(clearQueue).toHaveBeenCalledTimes(1) }) @@ -101,4 +104,20 @@ describe('flush-worker main()', () => { expect(fetch).not.toHaveBeenCalled() expect(writeQueue).not.toHaveBeenCalled() }) + + test('returns silently when postUrl is missing', async () => { + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY })] + await main() + expect(fetch).not.toHaveBeenCalled() + }) + + test('uses default headers when headers omitted from payload', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue({ ok: true }) + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY, postUrl: PROXY })] + await main() + expect(fetch.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ 'Content-Type': 'application/json' }) + ) + }) }) diff --git a/test/hooks.test.js b/test/hooks.test.js index 87fced3..1c8a677 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -22,13 +22,13 @@ jest.mock('child_process', () => ({ const fetch = createFetch() const { spawn } = require('child_process') +const telemetryLib = require('../src/telemetry-lib') const mockPackageJson = { bin: { aio: '' }, name: 'name', aioTelemetry: { - fetchHeaders: { 'Content-Type': 'application/json' }, - postUrl: 'https://httpstat.us/200' + fetchHeaders: { 'Content-Type': 'application/json' } } } @@ -36,9 +36,12 @@ describe('hook interfaces', () => { beforeEach(() => { fetch.mockReset() spawn.mockClear() + config.get.mockReset() }) test('command-error', async () => { + config.get.mockImplementation((key) => (String(key).includes('optOut') ? false : 'clientid')) + telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/command-error') expect(typeof hook).toBe('function') await hook({ message: 'msg' }) @@ -48,6 +51,8 @@ describe('hook interfaces', () => { }) test('command-not-found', async () => { + config.get.mockImplementation((key) => (String(key).includes('optOut') ? false : 'clientid')) + telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/command-not-found') expect(typeof hook).toBe('function') await hook({ id: 'id' }) @@ -151,7 +156,7 @@ describe('hook interfaces', () => { expect(typeof hook).toBe('function') inquirer.prompt = jest.fn().mockResolvedValue({ accept: false }) config.get = jest.fn().mockReturnValue(undefined) - await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--verbose'] }) + await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: ['--verbose'] }) expect(inquirer.prompt).toHaveBeenCalled() expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) @@ -161,6 +166,7 @@ describe('hook interfaces', () => { }) test('telemetry', async () => { + telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/telemetry') expect(typeof hook).toBe('function') config.get = jest @@ -175,6 +181,8 @@ describe('hook interfaces', () => { }) test('postrun', async () => { + config.get.mockImplementation((key) => (String(key).includes('optOut') ? false : 'clientid')) + telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/postrun') expect(typeof hook).toBe('function') await hook({ Command: { id: 'id' }, argv: ['--hello'] }) diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 2c11e50..3dd8908 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -40,7 +40,7 @@ describe('telemetry-lib', () => { test('exports init function', async () => { expect(telemetryLib.init).toBeDefined() expect(telemetryLib.init).toBeInstanceOf(Function) - telemetryLib.init('a@4', 'binTest') + telemetryLib.init('a@4', 'binTest', {}) telemetryLib.enable() expect(config.set).toHaveBeenCalledWith('binTest-cli-telemetry.optOut', false) telemetryLib.disable() @@ -49,18 +49,52 @@ describe('telemetry-lib', () => { test('uses client id from config', async () => { config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest2') + telemetryLib.init('a@4', 'binTest2', {}) await telemetryLib.trackEvent('test-event') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.clientId') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.optOut', 'global') expect(spawn).toHaveBeenCalled() const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) expect(flushPayload.body).toContain('"clientId":"clientidxyz"') + expect(flushPayload.postUrl).toBe(telemetryLib.DEFAULT_TELEMETRY_POST_URL) + }) + + test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => { + global.prerunTimer = Date.now() - 40 + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binDur', {}) + await telemetryLib.trackEvent('postrun') + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + const ed = JSON.parse(body[0].metrics[0].attributes.eventData) + expect(ed.durationMs).toBeGreaterThanOrEqual(35) + expect(ed.durationMs).toBeLessThan(60000) + }) + + test('postrun keeps non-empty eventData as-is', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binKeep', {}) + await telemetryLib.trackEvent('postrun', { source: 'test' }) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(JSON.parse(body[0].metrics[0].attributes.eventData)).toEqual({ source: 'test' }) + }) + + test('postrun eventData is {} when payload empty and prerunTimer is not a number', async () => { + const prev = global.prerunTimer + delete global.prerunTimer + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binNoTimer', {}) + await telemetryLib.trackEvent('postrun') + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(body[0].metrics[0].attributes.eventData).toBe('{}') + global.prerunTimer = prev }) test('trackEvent includes invocation_context and agent_name in payload', async () => { config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest') + telemetryLib.init('a@4', 'binTest', {}) await telemetryLib.trackEvent('postrun') expect(spawn).toHaveBeenCalled() const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) @@ -71,11 +105,61 @@ describe('telemetry-lib', () => { expect(['agent', 'human']).toContain(attributes.invocation_context) }) + test('init uses built-in default postUrl when host omits aioTelemetry.postUrl and env', async () => { + const orig = process.env.AIO_TELEMETRY_POST_URL + delete process.env.AIO_TELEMETRY_POST_URL + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binDefaultUrl', {}) + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.postUrl).toBe(telemetryLib.DEFAULT_TELEMETRY_POST_URL) + if (orig !== undefined) process.env.AIO_TELEMETRY_POST_URL = orig + }) + + test('init uses AIO_TELEMETRY_POST_URL when remoteConf.postUrl is omitted', async () => { + const orig = process.env.AIO_TELEMETRY_POST_URL + process.env.AIO_TELEMETRY_POST_URL = 'https://env.example/ingest' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binEnv', {}) + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.postUrl).toBe('https://env.example/ingest') + if (orig !== undefined) process.env.AIO_TELEMETRY_POST_URL = orig + else delete process.env.AIO_TELEMETRY_POST_URL + }) + + test('init with two args defaults remoteConf and uses AIO_TELEMETRY_POST_URL', async () => { + const orig = process.env.AIO_TELEMETRY_POST_URL + process.env.AIO_TELEMETRY_POST_URL = 'https://env-default.example/ingest' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binTwoArg') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.postUrl).toBe('https://env-default.example/ingest') + if (orig !== undefined) process.env.AIO_TELEMETRY_POST_URL = orig + else delete process.env.AIO_TELEMETRY_POST_URL + }) + + test('remoteConf.postUrl takes precedence over AIO_TELEMETRY_POST_URL', async () => { + const orig = process.env.AIO_TELEMETRY_POST_URL + process.env.AIO_TELEMETRY_POST_URL = 'https://env.example/ingest' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binPrec', { postUrl: 'https://cli-config.example/proxy' }) + await telemetryLib.trackEvent('postrun') + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.postUrl).toBe('https://cli-config.example/proxy') + if (orig !== undefined) process.env.AIO_TELEMETRY_POST_URL = orig + else delete process.env.AIO_TELEMETRY_POST_URL + }) + test('trackEvent does not post when AIO_TELEMETRY_DISABLED is set', async () => { const orig = process.env.AIO_TELEMETRY_DISABLED process.env.AIO_TELEMETRY_DISABLED = '1' config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest') + telemetryLib.init('a@4', 'binTest', {}) await telemetryLib.trackEvent('postrun') expect(spawn).not.toHaveBeenCalled() if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig @@ -86,7 +170,7 @@ describe('telemetry-lib', () => { const orig = process.env.CURSOR_AGENT process.env.CURSOR_AGENT = '1' config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest') + telemetryLib.init('a@4', 'binTest', {}) await telemetryLib.trackEvent('postrun') expect(spawn).toHaveBeenCalled() const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) @@ -99,6 +183,12 @@ describe('telemetry-lib', () => { }) }) +describe('resolveEventData', () => { + test('non-postrun with undefined raw yields {}', () => { + expect(telemetryLib.resolveEventData('command-error', undefined)).toEqual({}) + }) +}) + describe('getInvocationContext', () => { test('returns human when no agent env vars are set', () => { const result = telemetryLib.getInvocationContext({}) @@ -211,7 +301,7 @@ describe('AIO_TELEMETRY_DISABLED', () => { beforeEach(() => { orig = process.env.AIO_TELEMETRY_DISABLED process.env.AIO_TELEMETRY_DISABLED = '1' - telemetryLib.init('a@4', 'binTest') + telemetryLib.init('a@4', 'binTest', {}) }) afterEach(() => { From 3e1701dd093a1a902db2b36823c74f0f2fd0d62a Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 8 May 2026 17:54:18 -0700 Subject: [PATCH 07/31] remove extra logging flag --- src/flush-worker.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index 2fbae60..ba335f6 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -32,8 +32,7 @@ const { readQueue, writeQueue, clearQueue } = require('./queue-store') const fetch = createFetch() const DEFAULT_HEADERS = { - 'Content-Type': 'application/json', - 'x-ow-extra-logging': 'on' + 'Content-Type': 'application/json' } /** From 6a6df5713f83d684594b4b6795ace8155ab0550d Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 8 May 2026 18:30:58 -0700 Subject: [PATCH 08/31] remove newrelic leftovers --- src/telemetry-lib.js | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index aa61ad5..bc8d60e 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -80,7 +80,7 @@ function getInvocationContext (env) { const osNameVersion = `${os.type()} ${os.release()}` -// this is set by the init hook, ex. @adobe/aio-cli@8.2.0q +// this is set by the init hook, ex. @adobe/aio-cli@8.2.0 let rootCliVersion = '?' let prerunEvent = { flags: [] } @@ -92,20 +92,6 @@ let fetchHeaders = { let postUrl = DEFAULT_TELEMETRY_POST_URL let configKey = 'aio-cli-telemetry' -/** - * Strips New Relic credential header names from a header map (CLI must not send ingest keys). - * @param {Record|null|undefined} headers - optional header map from package.json aioTelemetry - * @returns {Record} copy without Api-Key fields - */ -function withoutNrKeys (headers) { - if (!headers || typeof headers !== 'object') { - return {} - } - const out = { ...headers } - delete out['Api-Key'] - delete out['api-key'] - return out -} const defaultPrivacyPolicyLink = 'https://developer.adobe.com/app-builder/docs/guides/telemetry/' /** @@ -231,8 +217,7 @@ module.exports = { rootCliVersion = versionString postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL fetchHeaders = { - 'Content-Type': 'application/json', - ...withoutNrKeys(remoteConf.fetchHeaders) + 'Content-Type': 'application/json' } configKey = binName + '-cli-telemetry' }, From e2a56c7d0363360152f31ca500a5bc92d0ad25ab Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Fri, 8 May 2026 18:54:47 -0700 Subject: [PATCH 09/31] catch spawn errors Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/telemetry-lib.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index bc8d60e..dfaa826 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -192,12 +192,16 @@ async function trackEvent (eventType, rawEventData = {}) { postUrl, headers: fetchHeaders }) - const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { - env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' }, - detached: true, - stdio: 'ignore' - }) - child.unref() + try { + const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { + env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' }, + detached: true, + stdio: 'ignore' + }) + child.unref() + } catch (err) { + debug('Failed to launch telemetry flush worker: %O', err) + } } } From fdc10ae61a718cfc6952f801c1327214e442e32f Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 11:51:31 -0700 Subject: [PATCH 10/31] sanitize mixed case headers Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/flush-worker.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index ba335f6..43bd3ea 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -53,9 +53,9 @@ async function main () { } postUrl = url if (headers && typeof headers === 'object') { - const safe = { ...headers } - delete safe['Api-Key'] - delete safe['api-key'] + const safe = Object.fromEntries( + Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key') + ) requestHeaders = { ...DEFAULT_HEADERS, ...safe } } currentMetrics = JSON.parse(body)[0].metrics From b5f6072b159a27e5e8154737f6732691b2ff62b9 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 11:56:20 -0700 Subject: [PATCH 11/31] worker checks for ok before clearing queue --- src/flush-worker.js | 15 ++++++++++----- test/flush-worker.test.js | 36 ++++++++++++++++++++++++++++++++++++ test/telemetry-lib.test.js | 9 +++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index 43bd3ea..93f2848 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -18,9 +18,9 @@ governing permissions and limitations under the License. * New Relic metric payload (array of metric batches) and postUrl is the App Builder proxy. * * On each run the worker merges any previously-failed events from the persistent - * queue (src/queue-store.js) with the current event before POSTing. On success - * the queue is cleared; on failure the merged set is written back so the next - * invocation can retry. + * queue (src/queue-store.js) with the current event before POSTing. On HTTP 2xx + * the queue is cleared; on network errors or non-2xx responses the merged set + * is written back so the next invocation can retry. */ 'use strict' @@ -71,15 +71,20 @@ async function main () { try { debug('POST %s requestHeaders=%o', postUrl, requestHeaders) - await fetch(postUrl, { + const res = await fetch(postUrl, { method: 'POST', headers: requestHeaders, body: JSON.stringify({ batches: [{ metrics: allMetrics }] }) }) + // fetch resolves on 4xx/5xx; only 2xx counts as success so we do not drop queued metrics. + if (!res?.ok) { + const status = res?.status ?? 'unknown' + throw new Error(`telemetry flush failed: HTTP ${status}`) + } // Successful delivery — the queue is no longer needed. clearQueue() } catch { - // Network failure — persist all metrics so the next invocation can retry. + // Network or HTTP failure — persist all metrics so the next invocation can retry. writeQueue(allMetrics) } } diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js index 276269b..e3b5cad 100644 --- a/test/flush-worker.test.js +++ b/test/flush-worker.test.js @@ -78,6 +78,42 @@ describe('flush-worker main()', () => { expect(clearQueue).not.toHaveBeenCalled() }) + test('writes merged metrics to queue when fetch resolves with non-ok response', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue({ ok: false, status: 503 }) + + process.argv = ['node', 'flush-worker.js', flushArg()] + await main() + + expect(writeQueue).toHaveBeenCalledTimes(1) + expect(writeQueue).toHaveBeenCalledWith([METRIC]) + expect(clearQueue).not.toHaveBeenCalled() + }) + + test('writes merged metrics to queue when fetch resolves with ok false and no status', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue({ ok: false }) + + process.argv = ['node', 'flush-worker.js', flushArg()] + await main() + + expect(writeQueue).toHaveBeenCalledTimes(1) + expect(writeQueue).toHaveBeenCalledWith([METRIC]) + expect(clearQueue).not.toHaveBeenCalled() + }) + + test('writes merged metrics to queue when fetch resolves with undefined response', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue(undefined) + + process.argv = ['node', 'flush-worker.js', flushArg()] + await main() + + expect(writeQueue).toHaveBeenCalledTimes(1) + expect(writeQueue).toHaveBeenCalledWith([METRIC]) + expect(clearQueue).not.toHaveBeenCalled() + }) + test('merges empty queue with current event', async () => { readQueue.mockReturnValue([]) fetch.mockResolvedValue({ ok: true }) diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 3dd8908..2611376 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -47,6 +47,15 @@ describe('telemetry-lib', () => { expect(config.set).toHaveBeenCalledWith('binTest-cli-telemetry.optOut', true) }) + test('trackEvent does not throw when spawn fails while launching flush worker', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binSpawnFail', {}) + spawn.mockImplementationOnce(() => { + throw new Error('spawn EPERM') + }) + await expect(telemetryLib.trackEvent('postrun')).resolves.toBeUndefined() + }) + test('uses client id from config', async () => { config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binTest2', {}) From c0acdfb349ef903502feffccadcf1cf77e64f00c Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 12:29:38 -0700 Subject: [PATCH 12/31] only flush queue on postrun, 1 command can produce multiple events, but only 1 server call --- src/flush-worker.js | 9 ++++---- src/queue-store.js | 23 +++++++++++++++---- src/telemetry-lib.js | 12 ++++++++-- test/hooks.test.js | 46 +++++++++++++++++++++++--------------- test/queue-store.test.js | 29 ++++++++++++++++++++++++ test/telemetry-lib.test.js | 35 ++++++++++++++++++++++++----- 6 files changed, 119 insertions(+), 35 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index 93f2848..1372d33 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -10,17 +10,16 @@ governing permissions and limitations under the License. */ /** - * Telemetry flush worker — spawned as a detached subprocess by trackEvent so + * Telemetry flush worker — spawned as a detached subprocess on `postrun` only so * the parent process can exit immediately without waiting on the HTTP POST. * * Accepts a single CLI argument: a JSON-encoded object with shape * { body: string, postUrl: string, headers?: object } where body is a serialised * New Relic metric payload (array of metric batches) and postUrl is the App Builder proxy. * - * On each run the worker merges any previously-failed events from the persistent - * queue (src/queue-store.js) with the current event before POSTing. On HTTP 2xx - * the queue is cleared; on network errors or non-2xx responses the merged set - * is written back so the next invocation can retry. + * The worker merges metrics from the persistent queue (written before postrun by + * trackEvent) with the postrun batch, POSTs, then on HTTP 2xx clears the queue; + * on network errors or non-2xx responses the merged set is written back for retry. */ 'use strict' diff --git a/src/queue-store.js b/src/queue-store.js index fdf9310..33f9b4c 100644 --- a/src/queue-store.js +++ b/src/queue-store.js @@ -10,15 +10,17 @@ governing permissions and limitations under the License. */ /** - * Persistent queue store for telemetry events that failed to POST. + * Persistent queue store for telemetry metrics pending flush or retry. * * The queue is kept in a dedicated JSON file that lives alongside the main aio * config directory but is completely separate from user-visible aio configuration: * * ${XDG_CONFIG_HOME:-~/.config}/aio/.telemetry-queue.json * - * On the next CLI invocation the flush worker picks up any queued events, merges - * them with the new event, and retries the batch. On success the file is removed. + * Metrics recorded before `postrun` are appended here; the flush worker runs on + * `postrun`, merges the file with the outgoing postrun batch, POSTs, then clears + * the file on success or rewrites it on failure for retry. A prior run may also + * leave failed deliveries in this file for the next flush. */ 'use strict' @@ -66,6 +68,19 @@ function writeQueue (items) { } } +/** + * Appends metric objects to the end of the queue (read + merge + write). + * Silently ignores invalid input or write errors. + * @param {Array} newItems Flat array of New Relic metric objects. + */ +function appendToQueue (newItems) { + if (!Array.isArray(newItems) || newItems.length === 0) { + return + } + const existing = readQueue() + writeQueue([...existing, ...newItems]) +} + /** * Removes the queue file. * Silently ignores errors (e.g. the file does not exist). @@ -78,4 +93,4 @@ function clearQueue () { } } -module.exports = { getQueuePath, readQueue, writeQueue, clearQueue } +module.exports = { getQueuePath, readQueue, writeQueue, appendToQueue, clearQueue } diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index dfaa826..6993254 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -13,6 +13,7 @@ const { spawn } = require('child_process') const path = require('path') const os = require('os') const config = require('@adobe/aio-lib-core-config') +const queueStore = require('./queue-store') const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') @@ -145,8 +146,7 @@ function resolveEventData (eventType, raw) { * @returns {undefined} */ async function trackEvent (eventType, rawEventData = {}) { - // prerunEvent will be null when telemetry-prompt event fires, this happens before - // any command is actually run, so we want to ignore the command+flags in this case + // prerunEvent is minimal before prerun; telemetry-prompt and similar fire before a command runs. const eventData = resolveEventData(eventType, rawEventData) @@ -187,6 +187,14 @@ async function trackEvent (eventType, rawEventData = {}) { }] }]) } + const metrics = JSON.parse(fetchConfig.body)[0].metrics + + if (eventType !== 'postrun') { + // Queue until postrun so only one detached flush worker runs per command (avoids concurrent merges). + queueStore.appendToQueue(metrics) + return + } + const flushPayload = JSON.stringify({ body: fetchConfig.body, postUrl, diff --git a/test/hooks.test.js b/test/hooks.test.js index 1c8a677..51addba 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -22,6 +22,7 @@ jest.mock('child_process', () => ({ const fetch = createFetch() const { spawn } = require('child_process') +const queueStore = require('../src/queue-store') const telemetryLib = require('../src/telemetry-lib') const mockPackageJson = { @@ -33,10 +34,19 @@ const mockPackageJson = { } describe('hook interfaces', () => { + beforeAll(() => { + jest.spyOn(queueStore, 'appendToQueue').mockImplementation(() => {}) + }) + + afterAll(() => { + queueStore.appendToQueue.mockRestore() + }) + beforeEach(() => { fetch.mockReset() spawn.mockClear() config.get.mockReset() + queueStore.appendToQueue.mockClear() }) test('command-error', async () => { @@ -45,9 +55,9 @@ describe('hook interfaces', () => { const hook = require('../src/hooks/command-error') expect(typeof hook).toBe('function') await hook({ message: 'msg' }) - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"eventType":"command-error"') + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"command-error"') }) test('command-not-found', async () => { @@ -56,9 +66,9 @@ describe('hook interfaces', () => { const hook = require('../src/hooks/command-not-found') expect(typeof hook).toBe('function') await hook({ id: 'id' }) - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"eventType":"command-not-found"') + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"command-not-found"') }) /** @@ -74,10 +84,10 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: [] }) expect(inquirer.prompt).toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"eventType":"telemetry-prompt"') - expect(flushPayload.body).toContain('accepted') + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-prompt"') + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('accepted') process.env = preEnv }) @@ -158,10 +168,10 @@ describe('hook interfaces', () => { config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: ['--verbose'] }) expect(inquirer.prompt).toHaveBeenCalled() - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"eventType":"telemetry-prompt"') - expect(flushPayload.body).toContain('declined') + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-prompt"') + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('declined') process.env = preEnv }) @@ -174,10 +184,10 @@ describe('hook interfaces', () => { .mockReturnValueOnce('clientid') .mockReturnValueOnce(false) - await hook({ message: 'msg' }) - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"eventType":"telemetry-custom-event"') + await hook({ data: { feature: 'x' } }) + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-custom-event"') }) test('postrun', async () => { diff --git a/test/queue-store.test.js b/test/queue-store.test.js index f73a22e..17b940b 100644 --- a/test/queue-store.test.js +++ b/test/queue-store.test.js @@ -62,6 +62,35 @@ describe('queue-store', () => { }) }) + describe('appendToQueue', () => { + test('merges new metrics after existing file contents', () => { + const existing = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'a' } }] + const incoming = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'b' } }] + fs.readFileSync.mockReturnValue(JSON.stringify(existing)) + queueStore.appendToQueue(incoming) + expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(DEFAULT_QUEUE_PATH), { recursive: true }) + expect(fs.writeFileSync).toHaveBeenCalledWith( + DEFAULT_QUEUE_PATH, + JSON.stringify([...existing, ...incoming]), + 'utf8' + ) + }) + + test('writes only new metrics when read fails', () => { + fs.readFileSync.mockImplementation(() => { throw new Error('ENOENT') }) + const incoming = [{ name: 'aio.cli.telemetry', value: 1 }] + queueStore.appendToQueue(incoming) + expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify(incoming), 'utf8') + }) + + test('no-op when newItems is empty or not an array', () => { + fs.readFileSync.mockReturnValue('[]') + queueStore.appendToQueue([]) + queueStore.appendToQueue(null) + expect(fs.writeFileSync).not.toHaveBeenCalled() + }) + }) + describe('writeQueue', () => { test('creates directory and writes items as JSON', () => { const items = [{ name: 'aio.cli.telemetry', value: 1 }] diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 2611376..2793090 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -13,6 +13,7 @@ const { createFetch } = require('@adobe/aio-lib-core-networking') const telemetryLib = require('../src/telemetry-lib') const config = require('@adobe/aio-lib-core-config') +const queueStore = require('../src/queue-store') jest.mock('@adobe/aio-lib-core-config') jest.mock('child_process', () => ({ @@ -23,10 +24,19 @@ const fetch = createFetch() const { spawn } = require('child_process') describe('telemetry-lib', () => { + beforeAll(() => { + jest.spyOn(queueStore, 'appendToQueue').mockImplementation(() => {}) + }) + + afterAll(() => { + queueStore.appendToQueue.mockRestore() + }) + beforeEach(() => { jest.resetModules() fetch.mockReset() spawn.mockClear() + queueStore.appendToQueue.mockClear() }) test('exports messages', async () => { @@ -56,16 +66,17 @@ describe('telemetry-lib', () => { await expect(telemetryLib.trackEvent('postrun')).resolves.toBeUndefined() }) - test('uses client id from config', async () => { + test('uses client id from config when queueing non-postrun events', async () => { config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binTest2', {}) - await telemetryLib.trackEvent('test-event') + await telemetryLib.trackEvent('telemetry-custom-event') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.clientId') expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.optOut', 'global') - expect(spawn).toHaveBeenCalled() - const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) - expect(flushPayload.body).toContain('"clientId":"clientidxyz"') - expect(flushPayload.postUrl).toBe(telemetryLib.DEFAULT_TELEMETRY_POST_URL) + expect(spawn).not.toHaveBeenCalled() + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + const appended = queueStore.appendToQueue.mock.calls[0][0] + expect(appended).toHaveLength(1) + expect(JSON.stringify(appended[0])).toContain('"clientId":"clientidxyz"') }) test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => { @@ -175,6 +186,18 @@ describe('telemetry-lib', () => { else delete process.env.AIO_TELEMETRY_DISABLED }) + test('trackEvent does not queue when AIO_TELEMETRY_DISABLED is set for non-postrun', async () => { + const orig = process.env.AIO_TELEMETRY_DISABLED + process.env.AIO_TELEMETRY_DISABLED = '1' + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binNoQueue', {}) + await telemetryLib.trackEvent('command-error', { message: 'x' }) + expect(queueStore.appendToQueue).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED + }) + test('trackEvent sends agent context when CURSOR_AGENT env is set', async () => { const orig = process.env.CURSOR_AGENT process.env.CURSOR_AGENT = '1' From e40f5bd16d44228dab9cac0b655f0de2a75b673e Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 13:42:43 -0700 Subject: [PATCH 13/31] document override of telemetry postUrl --- README.md | 32 +++++++++++++++++++++++++++++++- src/flush-worker.js | 3 ++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4258a02..eba86c7 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,42 @@ _See code: [src/commands/telemetry/index.js](https://github.com/adobe/aio-cli-pl When this plugin is hosted by different CLIs: -- `aioTelemetry` (optional object in the root `package.json`): +- `aioTelemetry` (optional object in the root `package.json` of the **host CLI** — the same `pjson` oclif passes into the init hook): + - `postUrl` (optional string): HTTPS URL of the telemetry proxy that receives POSTed metric batches. Use this when your CLI should send telemetry to a different App Builder action or gateway than the plugin default. - `fetchHeaders`: Optional extra headers merged into telemetry requests (`Content-Type` is always set) - `productPrivacyPolicyLink`: A link to display to users when prompting to opt in - `productName`: How to refer to the CLI when the user is prompted to enable telemetry (from `displayName` or `name` in `package.json`) - `productBin`: Shown in help text (from `bin` in `package.json`; if several bins exist, the first is used). Example: run `${productBin} telemetry on` +### Overriding the telemetry POST URL + +Resolution order (first match wins): + +1. **`aioTelemetry.postUrl`** in the host CLI `package.json` +2. **`AIO_TELEMETRY_POST_URL`** environment variable (non-empty string) +3. **Built-in default** in the plugin (`DEFAULT_TELEMETRY_POST_URL` in [`src/telemetry-lib.js`](https://github.com/adobe/aio-cli-plugin-telemetry/blob/master/src/telemetry-lib.js)) + +Host `package.json` example: + +```json +{ + "name": "my-cli", + "bin": { "mycli": "./bin/run.js" }, + "aioTelemetry": { + "postUrl": "https://-.adobeio-static.net/api/v1/web//" + } +} +``` + +Environment override (no `package.json` change; useful for CI, staging, or local proxy debugging): + +```sh +export AIO_TELEMETRY_POST_URL='https://-.adobeio-static.net/api/v1/web//' +mycli app deploy +``` + +The resolved URL is passed to the flush worker on **`postrun`**; it applies for the rest of that CLI process after `init` runs. + ## Opting out via environment variable Set `AIO_TELEMETRY_DISABLED=1` (or any truthy value) to suppress all telemetry without modifying the persisted opt-in state. Useful for CI pipelines and scripted environments. diff --git a/src/flush-worker.js b/src/flush-worker.js index 1372d33..6fcc15a 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -31,7 +31,8 @@ const { readQueue, writeQueue, clearQueue } = require('./queue-store') const fetch = createFetch() const DEFAULT_HEADERS = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'x-ow-extra-logging': 'on' } /** From 602deeeebaffeb98b80b965f0b0cfdd7f9d1ac5f Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 13:57:43 -0700 Subject: [PATCH 14/31] fix: formatting of eventData was inconsistent --- README.md | 2 ++ src/telemetry-lib.js | 27 ++++++++++++++++++++++- test/telemetry-lib.test.js | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eba86c7..481870a 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ To opt into agent tracking without setting a tool-specific variable, set `AIO_IN ## POST data +The `eventData` attribute is always a string. Objects and arrays are stored as a JSON text (e.g. `"{}"`, `"{\"message\":\"…\"}"`). String payloads (such as telemetry prompt outcomes `accepted` / `declined`) are stored as that plain text without an extra layer of JSON quoting. Numbers and booleans use their usual string forms (`"0"`, `"false"`). + Example shape of the metric payload: ```json diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 6993254..bf24684 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -138,6 +138,30 @@ function resolveEventData (eventType, raw) { return {} } +/** + * Serializes `eventData` for the metric `eventData` attribute (a string on the wire). + * Objects and arrays use `JSON.stringify`; string primitives are not double-encoded + * (so e.g. telemetry-prompt `"accepted"` stays `accepted`, not `"\"accepted\""`). + * + * @param {object|string|number|boolean|undefined|null} eventData - resolved payload from {@link resolveEventData} + * @returns {string} Serialized value for the metric `eventData` attribute (JSON for objects/arrays; plain text for strings). + */ +function formatEventDataAttribute (eventData) { + if (eventData === undefined) { + return '{}' + } + if (typeof eventData === 'string') { + return eventData + } + if (['number', 'boolean', 'bigint'].includes(typeof eventData)) { + return String(eventData) + } + if (typeof eventData === 'object') { + return JSON.stringify(eventData) + } + return String(eventData) +} + /** * @description tracks the event * @param {string} eventType prerun, postrun, command-error, command-not-found, telemetry @@ -172,7 +196,7 @@ async function trackEvent (eventType, rawEventData = {}) { timestamp, attributes: { eventType, - eventData: JSON.stringify(eventData), + eventData: formatEventDataAttribute(eventData), cliVersion: rootCliVersion, clientId, command: prerunEvent.command, @@ -254,6 +278,7 @@ module.exports = { // secret api for testing DEFAULT_TELEMETRY_POST_URL, resolveEventData, + formatEventDataAttribute, reset: () => { config.delete(configKey) }, diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 2793090..eccc0d4 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -198,6 +198,16 @@ describe('telemetry-lib', () => { else delete process.env.AIO_TELEMETRY_DISABLED }) + test('string eventData is stored without extra JSON quotes when queueing', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binStrEd', {}) + await telemetryLib.trackEvent('telemetry-prompt', 'accepted') + expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) + const metric = queueStore.appendToQueue.mock.calls[0][0][0] + expect(metric.attributes.eventData).toBe('accepted') + expect(metric.attributes.eventData).not.toMatch(/^"/) + }) + test('trackEvent sends agent context when CURSOR_AGENT env is set', async () => { const orig = process.env.CURSOR_AGENT process.env.CURSOR_AGENT = '1' @@ -221,6 +231,40 @@ describe('resolveEventData', () => { }) }) +describe('formatEventDataAttribute', () => { + test('string is returned as-is', () => { + expect(telemetryLib.formatEventDataAttribute('accepted')).toBe('accepted') + expect(telemetryLib.formatEventDataAttribute('')).toBe('') + }) + + test('object and array use JSON.stringify', () => { + expect(telemetryLib.formatEventDataAttribute({ a: 1 })).toBe('{"a":1}') + expect(telemetryLib.formatEventDataAttribute([1, 2])).toBe('[1,2]') + expect(telemetryLib.formatEventDataAttribute({})).toBe('{}') + }) + + test('number and boolean use String()', () => { + expect(telemetryLib.formatEventDataAttribute(0)).toBe('0') + expect(telemetryLib.formatEventDataAttribute(false)).toBe('false') + }) + + test('bigint uses String()', () => { + expect(telemetryLib.formatEventDataAttribute(42n)).toBe('42') + }) + + test('symbol falls through to String()', () => { + expect(telemetryLib.formatEventDataAttribute(Symbol('s'))).toBe('Symbol(s)') + }) + + test('undefined yields {}', () => { + expect(telemetryLib.formatEventDataAttribute(undefined)).toBe('{}') + }) + + test('null yields JSON null token', () => { + expect(telemetryLib.formatEventDataAttribute(null)).toBe('null') + }) +}) + describe('getInvocationContext', () => { test('returns human when no agent env vars are set', () => { const result = telemetryLib.getInvocationContext({}) From cbe9afef5ffb7bfde42f2e2e3d5189c951723d0e Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Mon, 11 May 2026 15:21:43 -0700 Subject: [PATCH 15/31] fix:guard non-string value in pathValue --- src/telemetry-lib.js | 3 ++- test/telemetry-lib.test.js | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index bf24684..861a1e7 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -26,10 +26,11 @@ let isDisabledForCommand = false /** * Detects GitHub Copilot Chat command shims injected into PATH. * - * @param {string} pathValue - PATH environment variable value. + * @param {string|null|undefined} [pathValue] - PATH environment variable value. * @returns {string|null} Agent name when detected, otherwise null. */ function detectCopilotAgent (pathValue) { + if (!pathValue) return null if (pathValue.includes('github.copilot-chat/debugCommand') || pathValue.includes('github.copilot-chat/copilotCli')) { return 'github-copilot' } diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index eccc0d4..4077f77 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -313,6 +313,11 @@ describe('getInvocationContext', () => { expect(result).toEqual({ isAgent: false, agentName: null }) }) + test('returns human when PATH is null or undefined', () => { + expect(telemetryLib.getInvocationContext({ PATH: null })).toEqual({ isAgent: false, agentName: null }) + expect(telemetryLib.getInvocationContext({ PATH: undefined })).toEqual({ isAgent: false, agentName: null }) + }) + test('AGENT takes precedence over tool-specific when both set', () => { const result = telemetryLib.getInvocationContext({ AGENT: 'goose', CURSOR_AGENT: '1' }) expect(result).toEqual({ isAgent: true, agentName: 'goose' }) From df0001c815d769a9dd3ce3f75219c8b999f30be1 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Wed, 13 May 2026 14:55:58 -0700 Subject: [PATCH 16/31] update docs --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 481870a..1e73b43 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,11 @@ The resolved URL is passed to the flush worker on **`postrun`**; it applies for ## Opting out via environment variable -Set `AIO_TELEMETRY_DISABLED=1` (or any truthy value) to suppress all telemetry without modifying the persisted opt-in state. Useful for CI pipelines and scripted environments. +Telemetry is suppressed when `AIO_TELEMETRY_DISABLED` is **set** to a **non-empty** string. The plugin checks `process.env.AIO_TELEMETRY_DISABLED` as a JavaScript value: unset or `''` does not disable; any other string is truthy and disables telemetry. + +Because environment variables are always strings in Node.js, this is **not** boolean parsing: `AIO_TELEMETRY_DISABLED=0`, `=false`, or `=no` still disable telemetry (the values are the strings `"0"`, `"false"`, `"no"`, all of which are truthy). To run with telemetry allowed, **unset** the variable (or use an empty value if your environment exposes `''`). + +This does not change the persisted opt-in state. Useful for CI pipelines and scripted environments. ```sh AIO_TELEMETRY_DISABLED=1 aio app deploy @@ -88,6 +92,8 @@ AIO_TELEMETRY_DISABLED=1 aio app deploy Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI can exit without waiting for the HTTP request to finish. +Events queued on disk before `postrun` use `src/queue-store.js` (see that file for the path). The queue keeps at most **1000** metric objects; if the proxy stays down and the queue would grow past that, the oldest entries are dropped so the JSON file cannot grow without bound. + ## Agent detection The plugin detects whether the CLI is being invoked by an AI agent or a human by inspecting environment variables at the time of the event. The detected context is included in every event as `invocation_context` (`"agent"` or `"human"`) and `agent_name`. From 8726ccbfe1470d866b6fd376e803e4f8192edba0 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Wed, 13 May 2026 14:57:08 -0700 Subject: [PATCH 17/31] cap metric count for long offline periods --- src/flush-worker.js | 7 ++--- src/queue-store.js | 50 +++++++++++++++++++++++++++++++++--- src/telemetry-lib.js | 21 ++++++++++----- test/flush-worker.test.js | 21 ++++++++++++++- test/hooks.test.js | 5 ++-- test/queue-store.test.js | 52 ++++++++++++++++++++++++++++++++++++++ test/telemetry-lib.test.js | 19 ++++++++++++++ 7 files changed, 158 insertions(+), 17 deletions(-) diff --git a/src/flush-worker.js b/src/flush-worker.js index 6fcc15a..7492977 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -15,7 +15,9 @@ governing permissions and limitations under the License. * * Accepts a single CLI argument: a JSON-encoded object with shape * { body: string, postUrl: string, headers?: object } where body is a serialised - * New Relic metric payload (array of metric batches) and postUrl is the App Builder proxy. + * New Relic metric payload (array of metric batches), postUrl is the App Builder proxy, + * and headers (when present) are optional overrides merged after the worker defaults + * (never pass secrets such as api-key). * * The worker merges metrics from the persistent queue (written before postrun by * trackEvent) with the postrun batch, POSTs, then on HTTP 2xx clears the queue; @@ -31,8 +33,7 @@ const { readQueue, writeQueue, clearQueue } = require('./queue-store') const fetch = createFetch() const DEFAULT_HEADERS = { - 'Content-Type': 'application/json', - 'x-ow-extra-logging': 'on' + 'Content-Type': 'application/json' } /** diff --git a/src/queue-store.js b/src/queue-store.js index 33f9b4c..e9077b6 100644 --- a/src/queue-store.js +++ b/src/queue-store.js @@ -21,6 +21,9 @@ governing permissions and limitations under the License. * `postrun`, merges the file with the outgoing postrun batch, POSTs, then clears * the file on success or rewrites it on failure for retry. A prior run may also * leave failed deliveries in this file for the next flush. + * + * Stored metrics are capped at `MAX_QUEUE_METRICS`; older entries are dropped when + * the limit is exceeded so an unreachable proxy cannot grow the file without bound. */ 'use strict' @@ -29,6 +32,24 @@ const fs = require('fs') const path = require('path') const os = require('os') +/** Maximum metric objects in the queue file; oldest are evicted when exceeded. */ +const MAX_QUEUE_METRICS = 1000 + +/** + * Truncates a metrics array to {@link MAX_QUEUE_METRICS} entries (keeps the newest). + * @param {unknown} items candidate queue contents (typically an array from JSON) + * @returns {Array} sanitized array safe to persist + */ +function normalizeQueueItems (items) { + if (!Array.isArray(items)) { + return [] + } + if (items.length <= MAX_QUEUE_METRICS) { + return items + } + return items.slice(-MAX_QUEUE_METRICS) +} + /** * Resolves the absolute path to the queue file, honouring XDG_CONFIG_HOME when set. * @returns {string} Absolute path to .telemetry-queue.json. @@ -44,10 +65,23 @@ function getQueuePath () { * @returns {Array} Flat array of New Relic metric objects. */ function readQueue () { + const file = getQueuePath() try { - const data = fs.readFileSync(getQueuePath(), 'utf8') + const data = fs.readFileSync(file, 'utf8') const parsed = JSON.parse(data) - return Array.isArray(parsed) ? parsed : [] + if (!Array.isArray(parsed)) { + return [] + } + const normalized = normalizeQueueItems(parsed) + if (parsed.length > normalized.length) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, JSON.stringify(normalized), 'utf8') + } catch { + // same as writeQueue — never throw from telemetry persistence + } + } + return normalized } catch { return [] } @@ -60,9 +94,10 @@ function readQueue () { */ function writeQueue (items) { const file = getQueuePath() + const toWrite = normalizeQueueItems(items) try { fs.mkdirSync(path.dirname(file), { recursive: true }) - fs.writeFileSync(file, JSON.stringify(items), 'utf8') + fs.writeFileSync(file, JSON.stringify(toWrite), 'utf8') } catch { // silently ignore — failing to persist the queue must not affect the CLI } @@ -93,4 +128,11 @@ function clearQueue () { } } -module.exports = { getQueuePath, readQueue, writeQueue, appendToQueue, clearQueue } +module.exports = { + getQueuePath, + readQueue, + writeQueue, + appendToQueue, + clearQueue, + MAX_QUEUE_METRICS +} diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 861a1e7..6d5ae18 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -86,10 +86,15 @@ const osNameVersion = `${os.type()} ${os.release()}` let rootCliVersion = '?' let prerunEvent = { flags: [] } -/** @type {Record} Headers for the telemetry proxy POST (never include New Relic keys). */ -let fetchHeaders = { +/** Built-in request headers; the flush worker subprocess applies the same defaults for its POST. */ +const DEFAULT_FETCH_HEADERS = { 'Content-Type': 'application/json' } + +/** @type {Record} Full headers for in-process use (defaults + extras from host config). */ +let fetchHeaders = { ...DEFAULT_FETCH_HEADERS } +/** @type {Record} Host-only headers from `aioTelemetry.fetchHeaders` (passed to flush worker as overrides, not defaults). */ +let extraFetchHeaders = {} /** @type {string} Resolved proxy URL (defaults at module load; init may override). */ let postUrl = DEFAULT_TELEMETRY_POST_URL let configKey = 'aio-cli-telemetry' @@ -223,7 +228,7 @@ async function trackEvent (eventType, rawEventData = {}) { const flushPayload = JSON.stringify({ body: fetchConfig.body, postUrl, - headers: fetchHeaders + ...(Object.keys(extraFetchHeaders).length > 0 && { headers: { ...extraFetchHeaders } }) }) try { const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { @@ -253,9 +258,13 @@ module.exports = { global.commandHookStartTime = Date.now() rootCliVersion = versionString postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL - fetchHeaders = { - 'Content-Type': 'application/json' - } + const rawExtra = remoteConf.fetchHeaders && typeof remoteConf.fetchHeaders === 'object' + ? remoteConf.fetchHeaders + : {} + extraFetchHeaders = Object.fromEntries( + Object.entries(rawExtra).filter(([key]) => key.toLowerCase() !== 'api-key') + ) + fetchHeaders = { ...DEFAULT_FETCH_HEADERS, ...extraFetchHeaders } configKey = binName + '-cli-telemetry' }, getClientId, diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js index e3b5cad..6f14268 100644 --- a/test/flush-worker.test.js +++ b/test/flush-worker.test.js @@ -25,7 +25,13 @@ const { main } = require('../src/flush-worker') const PROXY = 'https://telemetry-proxy.example/api/v1/web/dx-excshell-1/telemetry' const METRIC = { name: 'aio.cli.telemetry', type: 'gauge', value: 1, timestamp: 1000, attributes: { eventType: 'postrun' } } const BODY = JSON.stringify([{ metrics: [METRIC] }]) -const flushArg = (body = BODY) => JSON.stringify({ body, postUrl: PROXY, headers: { 'Content-Type': 'application/json' } }) +const flushArg = (body = BODY, extraHeaders) => { + const base = { body, postUrl: PROXY } + if (extraHeaders !== undefined) { + base.headers = extraHeaders + } + return JSON.stringify(base) +} describe('flush-worker main()', () => { let origArgv @@ -127,6 +133,19 @@ describe('flush-worker main()', () => { expect(clearQueue).toHaveBeenCalledTimes(1) }) + test('merges optional payload headers as overrides after defaults', async () => { + readQueue.mockReturnValue([]) + fetch.mockResolvedValue({ ok: true }) + process.argv = ['node', 'flush-worker.js', flushArg(BODY, { 'X-Custom-Proxy': 'unit-test' })] + await main() + expect(fetch.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Custom-Proxy': 'unit-test' + }) + ) + }) + test('returns silently when argv[2] is missing', async () => { process.argv = ['node', 'flush-worker.js'] await main() diff --git a/test/hooks.test.js b/test/hooks.test.js index 51addba..c8f98c0 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -28,9 +28,7 @@ const telemetryLib = require('../src/telemetry-lib') const mockPackageJson = { bin: { aio: '' }, name: 'name', - aioTelemetry: { - fetchHeaders: { 'Content-Type': 'application/json' } - } + aioTelemetry: {} } describe('hook interfaces', () => { @@ -198,6 +196,7 @@ describe('hook interfaces', () => { await hook({ Command: { id: 'id' }, argv: ['--hello'] }) expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.headers).toBeUndefined() expect(flushPayload.body).toContain('"eventType":"postrun"') }) diff --git a/test/queue-store.test.js b/test/queue-store.test.js index 17b940b..743aa5d 100644 --- a/test/queue-store.test.js +++ b/test/queue-store.test.js @@ -60,6 +60,31 @@ describe('queue-store', () => { fs.readFileSync.mockReturnValue(JSON.stringify({ foo: 'bar' })) expect(queueStore.readQueue()).toEqual([]) }) + + test('truncates oversized file to MAX_QUEUE_METRICS and rewrites disk', () => { + const oversized = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 5 }, (_, i) => ({ i })) + fs.readFileSync.mockReturnValue(JSON.stringify(oversized)) + const result = queueStore.readQueue() + expect(result).toHaveLength(queueStore.MAX_QUEUE_METRICS) + expect(result[0].i).toBe(5) + expect(result[queueStore.MAX_QUEUE_METRICS - 1].i).toBe(queueStore.MAX_QUEUE_METRICS + 4) + expect(fs.writeFileSync).toHaveBeenCalledWith( + DEFAULT_QUEUE_PATH, + JSON.stringify(result), + 'utf8' + ) + }) + + test('returns truncated queue when shrink rewrite fails', () => { + const oversized = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 3 }, (_, i) => ({ i })) + fs.readFileSync.mockReturnValue(JSON.stringify(oversized)) + fs.writeFileSync.mockImplementation(() => { + throw new Error('EACCES') + }) + const result = queueStore.readQueue() + expect(result).toHaveLength(queueStore.MAX_QUEUE_METRICS) + expect(result[0].i).toBe(3) + }) }) describe('appendToQueue', () => { @@ -76,6 +101,17 @@ describe('queue-store', () => { ) }) + test('truncates when merged queue would exceed MAX_QUEUE_METRICS', () => { + const existing = Array.from({ length: queueStore.MAX_QUEUE_METRICS }, (_, i) => ({ tag: 'e', i })) + const incoming = [{ tag: 'n', x: 1 }, { tag: 'n', x: 2 }] + fs.readFileSync.mockReturnValue(JSON.stringify(existing)) + queueStore.appendToQueue(incoming) + const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]) + expect(written).toHaveLength(queueStore.MAX_QUEUE_METRICS) + expect(written[queueStore.MAX_QUEUE_METRICS - 2]).toEqual(incoming[0]) + expect(written[queueStore.MAX_QUEUE_METRICS - 1]).toEqual(incoming[1]) + }) + test('writes only new metrics when read fails', () => { fs.readFileSync.mockImplementation(() => { throw new Error('ENOENT') }) const incoming = [{ name: 'aio.cli.telemetry', value: 1 }] @@ -99,6 +135,22 @@ describe('queue-store', () => { expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify(items), 'utf8') }) + test('writeQueue keeps at most MAX_QUEUE_METRICS entries (newest)', () => { + const items = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 42 }, (_, i) => ({ i })) + const expected = items.slice(-queueStore.MAX_QUEUE_METRICS) + queueStore.writeQueue(items) + expect(fs.writeFileSync).toHaveBeenCalledWith( + DEFAULT_QUEUE_PATH, + JSON.stringify(expected), + 'utf8' + ) + }) + + test('writeQueue persists empty array when items is not an array', () => { + queueStore.writeQueue(null) + expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify([]), 'utf8') + }) + test('silently ignores write errors', () => { fs.mkdirSync.mockImplementation(() => { throw new Error('EACCES') }) expect(() => queueStore.writeQueue([])).not.toThrow() diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 4077f77..6a999d1 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -208,6 +208,25 @@ describe('telemetry-lib', () => { expect(metric.attributes.eventData).not.toMatch(/^"/) }) + test('postrun flush payload omits headers when host has no aioTelemetry.fetchHeaders', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binNoFlushHdr', {}) + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalled() + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.headers).toBeUndefined() + }) + + test('postrun flush payload passes only aioTelemetry.fetchHeaders as worker overrides', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binFlushHdr', { + fetchHeaders: { 'x-correlation-id': 'abc', 'api-key': 'nope' } + }) + await telemetryLib.trackEvent('postrun') + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + expect(flushPayload.headers).toEqual({ 'x-correlation-id': 'abc' }) + }) + test('trackEvent sends agent context when CURSOR_AGENT env is set', async () => { const orig = process.env.CURSOR_AGENT process.env.CURSOR_AGENT = '1' From 42a6e269f5dea5b5c8016514205a5a43a6e055c5 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Wed, 13 May 2026 15:06:04 -0700 Subject: [PATCH 18/31] fix: windows path. simpler copilot detection --- src/telemetry-lib.js | 5 +++-- test/telemetry-lib.test.js | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 6d5ae18..a4721ba 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -24,14 +24,15 @@ const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy-stage.ado let isDisabledForCommand = false /** - * Detects GitHub Copilot Chat command shims injected into PATH. + * Detects GitHub Copilot Chat on PATH via the extension id in globalStorage paths (any OS path separator). * * @param {string|null|undefined} [pathValue] - PATH environment variable value. * @returns {string|null} Agent name when detected, otherwise null. */ function detectCopilotAgent (pathValue) { if (!pathValue) return null - if (pathValue.includes('github.copilot-chat/debugCommand') || pathValue.includes('github.copilot-chat/copilotCli')) { + // Extension id appears in globalStorage paths on all platforms; do not tie to '/' (Windows uses '\'). + if (pathValue.includes('github.copilot-chat')) { return 'github-copilot' } return null diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 6a999d1..9d0f574 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -327,6 +327,13 @@ describe('getInvocationContext', () => { expect(result).toEqual({ isAgent: true, agentName: 'github-copilot' }) }) + test('returns github-copilot when PATH uses Windows separators', () => { + const result = telemetryLib.getInvocationContext({ + PATH: 'C:\\Program Files\\Git\\cmd;C:\\Users\\test\\AppData\\Roaming\\Code\\User\\globalStorage\\github.copilot-chat\\debugCommand' + }) + expect(result).toEqual({ isAgent: true, agentName: 'github-copilot' }) + }) + test('returns human when PATH does not contain Copilot Chat markers', () => { const result = telemetryLib.getInvocationContext({ PATH: '/usr/local/bin:/usr/bin:/bin' }) expect(result).toEqual({ isAgent: false, agentName: null }) From 11b631f24d135753d5722c38acfda323e216adcd Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 10:12:25 -0700 Subject: [PATCH 19/31] remve redundant mkdirSync --- src/queue-store.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/queue-store.js b/src/queue-store.js index e9077b6..2330b15 100644 --- a/src/queue-store.js +++ b/src/queue-store.js @@ -75,7 +75,6 @@ function readQueue () { const normalized = normalizeQueueItems(parsed) if (parsed.length > normalized.length) { try { - fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, JSON.stringify(normalized), 'utf8') } catch { // same as writeQueue — never throw from telemetry persistence From 08f9101b925a8dff058e3a9e7f0b4f63ab8686e2 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 10:16:34 -0700 Subject: [PATCH 20/31] header denylist Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/telemetry-lib.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index a4721ba..6bb31f5 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -262,7 +262,10 @@ module.exports = { const rawExtra = remoteConf.fetchHeaders && typeof remoteConf.fetchHeaders === 'object' ? remoteConf.fetchHeaders : {} + const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key']) extraFetchHeaders = Object.fromEntries( + Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase())) + ) Object.entries(rawExtra).filter(([key]) => key.toLowerCase() !== 'api-key') ) fetchHeaders = { ...DEFAULT_FETCH_HEADERS, ...extraFetchHeaders } From dadbd2b4923e36b994b857e42cb1b7e09a7cdbfc Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 10:21:25 -0700 Subject: [PATCH 21/31] Fix error introduced by copilot Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/telemetry-lib.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 6bb31f5..104ffda 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -263,12 +263,11 @@ module.exports = { ? remoteConf.fetchHeaders : {} const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key']) + extraFetchHeaders = Object.fromEntries( + Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase())) extraFetchHeaders = Object.fromEntries( Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase())) ) - Object.entries(rawExtra).filter(([key]) => key.toLowerCase() !== 'api-key') - ) - fetchHeaders = { ...DEFAULT_FETCH_HEADERS, ...extraFetchHeaders } configKey = binName + '-cli-telemetry' }, getClientId, From 3125ba82a6f31eb96b04f275d1e21958bf6f2aef Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 11:42:08 -0700 Subject: [PATCH 22/31] simplify to best-effort tracking --- README.md | 8 +- src/flush-worker.js | 47 ++++------ src/queue-store.js | 137 ----------------------------- src/telemetry-lib.js | 30 ++++--- test/flush-worker.test.js | 84 +++++------------- test/hooks.test.js | 50 ++++++----- test/queue-store.test.js | 171 ------------------------------------- test/telemetry-lib.test.js | 40 ++++----- 8 files changed, 108 insertions(+), 459 deletions(-) delete mode 100644 src/queue-store.js delete mode 100644 test/queue-store.test.js diff --git a/README.md b/README.md index 1e73b43..3450f50 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ export AIO_TELEMETRY_POST_URL='https://-.adobeio-static.net/ mycli app deploy ``` -The resolved URL is passed to the flush worker on **`postrun`**; it applies for the rest of that CLI process after `init` runs. +The resolved URL is passed to the flush worker on each telemetry send; it applies for the rest of that CLI process after `init` runs. ## Opting out via environment variable @@ -90,9 +90,11 @@ AIO_TELEMETRY_DISABLED=1 aio app deploy ## Flush architecture -Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI can exit without waiting for the HTTP request to finish. +Telemetry is **best-effort**: events are not persisted when the proxy is down or the network fails. -Events queued on disk before `postrun` use `src/queue-store.js` (see that file for the path). The queue keeps at most **1000** metric objects; if the proxy stays down and the queue would grow past that, the oldest entries are dropped so the JSON file cannot grow without bound. +On **`postrun`**, any in-memory metrics from earlier hooks in the same command are merged with the `postrun` metric and the combined batch is handed off to a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI spawns the worker and immediately `unref()`s it, so the CLI can exit without waiting for the HTTP POST. If the POST fails (network error or non-2xx response), the batch is dropped; telemetry must not block or slow normal CLI use. + +Non-`postrun` events (for example `command-error`, `telemetry-prompt`) are held in an **in-memory buffer** until that flush. If the process exits before `postrun` (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session). ## Agent detection diff --git a/src/flush-worker.js b/src/flush-worker.js index 7492977..5014eaa 100644 --- a/src/flush-worker.js +++ b/src/flush-worker.js @@ -10,25 +10,22 @@ governing permissions and limitations under the License. */ /** - * Telemetry flush worker — spawned as a detached subprocess on `postrun` only so - * the parent process can exit immediately without waiting on the HTTP POST. + * Telemetry flush worker — spawned as a detached subprocess so the parent CLI can + * exit immediately without waiting on the HTTP POST. * * Accepts a single CLI argument: a JSON-encoded object with shape - * { body: string, postUrl: string, headers?: object } where body is a serialised - * New Relic metric payload (array of metric batches), postUrl is the App Builder proxy, - * and headers (when present) are optional overrides merged after the worker defaults - * (never pass secrets such as api-key). + * { body: string, postUrl: string, headers?: object } where `body` is a serialised + * New Relic metric batch array (same shape as the parent builds for fetch), `postUrl` + * is the App Builder proxy, and `headers` (when present) are optional overrides merged + * after the worker defaults (never pass secrets such as api-key). * - * The worker merges metrics from the persistent queue (written before postrun by - * trackEvent) with the postrun batch, POSTs, then on HTTP 2xx clears the queue; - * on network errors or non-2xx responses the merged set is written back for retry. + * Failed deliveries are dropped; telemetry is best-effort and must not affect the CLI. */ 'use strict' const debug = require('debug')('aio-telemetry:flush-worker') const { createFetch } = require('@adobe/aio-lib-core-networking') -const { readQueue, writeQueue, clearQueue } = require('./queue-store') const fetch = createFetch() @@ -37,13 +34,11 @@ const DEFAULT_HEADERS = { } /** - * Reads the persistent queue, merges it with the current event, POSTs the batch, - * and either clears the queue on success or writes back on failure for retry. + * POSTs the metric batch from argv. Swallows all errors. * @returns {Promise} */ async function main () { - // Parse the current event payload passed by the parent process. - let currentMetrics + let batches let postUrl let requestHeaders = { ...DEFAULT_HEADERS } try { @@ -59,34 +54,28 @@ async function main () { ) requestHeaders = { ...DEFAULT_HEADERS, ...safe } } - currentMetrics = JSON.parse(body)[0].metrics + const parsedBody = JSON.parse(body) + if (!Array.isArray(parsedBody)) { + return + } + batches = parsedBody } catch { - // Malformed argument — nothing useful to do. return } - // Merge previously-queued metrics (if any) with the current event so they - // are all retried in a single POST. - const queuedMetrics = readQueue() - const allMetrics = [...queuedMetrics, ...currentMetrics] - try { debug('POST %s requestHeaders=%o', postUrl, requestHeaders) const res = await fetch(postUrl, { method: 'POST', headers: requestHeaders, - body: JSON.stringify({ batches: [{ metrics: allMetrics }] }) + body: JSON.stringify({ batches }) }) - // fetch resolves on 4xx/5xx; only 2xx counts as success so we do not drop queued metrics. if (!res?.ok) { const status = res?.status ?? 'unknown' - throw new Error(`telemetry flush failed: HTTP ${status}`) + debug('telemetry flush non-ok: HTTP %s', status) } - // Successful delivery — the queue is no longer needed. - clearQueue() - } catch { - // Network or HTTP failure — persist all metrics so the next invocation can retry. - writeQueue(allMetrics) + } catch (err) { + debug('telemetry flush failed: %O', err) } } diff --git a/src/queue-store.js b/src/queue-store.js deleted file mode 100644 index 2330b15..0000000 --- a/src/queue-store.js +++ /dev/null @@ -1,137 +0,0 @@ -/* -Copyright 2026 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -/** - * Persistent queue store for telemetry metrics pending flush or retry. - * - * The queue is kept in a dedicated JSON file that lives alongside the main aio - * config directory but is completely separate from user-visible aio configuration: - * - * ${XDG_CONFIG_HOME:-~/.config}/aio/.telemetry-queue.json - * - * Metrics recorded before `postrun` are appended here; the flush worker runs on - * `postrun`, merges the file with the outgoing postrun batch, POSTs, then clears - * the file on success or rewrites it on failure for retry. A prior run may also - * leave failed deliveries in this file for the next flush. - * - * Stored metrics are capped at `MAX_QUEUE_METRICS`; older entries are dropped when - * the limit is exceeded so an unreachable proxy cannot grow the file without bound. - */ - -'use strict' - -const fs = require('fs') -const path = require('path') -const os = require('os') - -/** Maximum metric objects in the queue file; oldest are evicted when exceeded. */ -const MAX_QUEUE_METRICS = 1000 - -/** - * Truncates a metrics array to {@link MAX_QUEUE_METRICS} entries (keeps the newest). - * @param {unknown} items candidate queue contents (typically an array from JSON) - * @returns {Array} sanitized array safe to persist - */ -function normalizeQueueItems (items) { - if (!Array.isArray(items)) { - return [] - } - if (items.length <= MAX_QUEUE_METRICS) { - return items - } - return items.slice(-MAX_QUEUE_METRICS) -} - -/** - * Resolves the absolute path to the queue file, honouring XDG_CONFIG_HOME when set. - * @returns {string} Absolute path to .telemetry-queue.json. - */ -function getQueuePath () { - const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') - return path.join(base, 'aio', '.telemetry-queue.json') -} - -/** - * Reads the current queue from disk. - * Returns an empty array when the file does not exist or is unreadable. - * @returns {Array} Flat array of New Relic metric objects. - */ -function readQueue () { - const file = getQueuePath() - try { - const data = fs.readFileSync(file, 'utf8') - const parsed = JSON.parse(data) - if (!Array.isArray(parsed)) { - return [] - } - const normalized = normalizeQueueItems(parsed) - if (parsed.length > normalized.length) { - try { - fs.writeFileSync(file, JSON.stringify(normalized), 'utf8') - } catch { - // same as writeQueue — never throw from telemetry persistence - } - } - return normalized - } catch { - return [] - } -} - -/** - * Persists the given metrics array to the queue file, creating directories as needed. - * Silently ignores write errors — telemetry must never crash the CLI. - * @param {Array} items Flat array of New Relic metric objects. - */ -function writeQueue (items) { - const file = getQueuePath() - const toWrite = normalizeQueueItems(items) - try { - fs.mkdirSync(path.dirname(file), { recursive: true }) - fs.writeFileSync(file, JSON.stringify(toWrite), 'utf8') - } catch { - // silently ignore — failing to persist the queue must not affect the CLI - } -} - -/** - * Appends metric objects to the end of the queue (read + merge + write). - * Silently ignores invalid input or write errors. - * @param {Array} newItems Flat array of New Relic metric objects. - */ -function appendToQueue (newItems) { - if (!Array.isArray(newItems) || newItems.length === 0) { - return - } - const existing = readQueue() - writeQueue([...existing, ...newItems]) -} - -/** - * Removes the queue file. - * Silently ignores errors (e.g. the file does not exist). - */ -function clearQueue () { - try { - fs.unlinkSync(getQueuePath()) - } catch { - // silently ignore — queue file may not exist - } -} - -module.exports = { - getQueuePath, - readQueue, - writeQueue, - appendToQueue, - clearQueue, - MAX_QUEUE_METRICS -} diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 104ffda..891f249 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -13,7 +13,6 @@ const { spawn } = require('child_process') const path = require('path') const os = require('os') const config = require('@adobe/aio-lib-core-config') -const queueStore = require('./queue-store') const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') @@ -23,6 +22,9 @@ const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy-stage.ado let isDisabledForCommand = false +/** Metrics for non-`postrun` events in the current command; merged into one POST on `postrun`. */ +const pendingCommandMetrics = [] + /** * Detects GitHub Copilot Chat on PATH via the extension id in globalStorage paths (any OS path separator). * @@ -92,8 +94,8 @@ const DEFAULT_FETCH_HEADERS = { 'Content-Type': 'application/json' } -/** @type {Record} Full headers for in-process use (defaults + extras from host config). */ -let fetchHeaders = { ...DEFAULT_FETCH_HEADERS } +/** @type {Record} Default headers for the metric payload (flush worker merges {@link extraFetchHeaders}). */ +const fetchHeaders = { ...DEFAULT_FETCH_HEADERS } /** @type {Record} Host-only headers from `aioTelemetry.fetchHeaders` (passed to flush worker as overrides, not defaults). */ let extraFetchHeaders = {} /** @type {string} Resolved proxy URL (defaults at module load; init may override). */ @@ -170,7 +172,10 @@ function formatEventDataAttribute (eventData) { } /** - * @description tracks the event + * Records a telemetry event. Non-`postrun` metrics are held in memory and sent in a single + * batched POST when `postrun` runs. When enabled, the flush worker is detached so the CLI + * never waits on the network; failed deliveries are dropped (no disk queue). + * * @param {string} eventType prerun, postrun, command-error, command-not-found, telemetry * @param {object|string|number|undefined} [rawEventData] Optional hook payload (e.g. `{ message }` on errors). * Command/flags/duration are also sent as separate metric attributes from prerun/postrun. @@ -186,6 +191,7 @@ async function trackEvent (eventType, rawEventData = {}) { debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend) if (optedOut) { + pendingCommandMetrics.length = 0 debug('Telemetry is off. Not logging telemetry event', eventType) } else { const clientId = getClientId() @@ -218,16 +224,20 @@ async function trackEvent (eventType, rawEventData = {}) { }] }]) } - const metrics = JSON.parse(fetchConfig.body)[0].metrics + const batch = JSON.parse(fetchConfig.body) + const metricsThisEvent = batch[0].metrics if (eventType !== 'postrun') { - // Queue until postrun so only one detached flush worker runs per command (avoids concurrent merges). - queueStore.appendToQueue(metrics) + pendingCommandMetrics.push(...metricsThisEvent) return } + const mergedMetrics = [...pendingCommandMetrics, ...metricsThisEvent] + pendingCommandMetrics.length = 0 + const mergedBody = JSON.stringify([{ metrics: mergedMetrics }]) + const flushPayload = JSON.stringify({ - body: fetchConfig.body, + body: mergedBody, postUrl, ...(Object.keys(extraFetchHeaders).length > 0 && { headers: { ...extraFetchHeaders } }) }) @@ -256,6 +266,7 @@ function trackPrerun (command, flags, start) { module.exports = { getInvocationContext, init: (versionString, binName, remoteConf = {}) => { + pendingCommandMetrics.length = 0 global.commandHookStartTime = Date.now() rootCliVersion = versionString postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL @@ -263,8 +274,6 @@ module.exports = { ? remoteConf.fetchHeaders : {} const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key']) - extraFetchHeaders = Object.fromEntries( - Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase())) extraFetchHeaders = Object.fromEntries( Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase())) ) @@ -293,6 +302,7 @@ module.exports = { resolveEventData, formatEventDataAttribute, reset: () => { + pendingCommandMetrics.length = 0 config.delete(configKey) }, getOnMessage, diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js index 6f14268..f47a15c 100644 --- a/test/flush-worker.test.js +++ b/test/flush-worker.test.js @@ -12,14 +12,7 @@ const { createFetch } = require('@adobe/aio-lib-core-networking') -jest.mock('../src/queue-store', () => ({ - readQueue: jest.fn(() => []), - writeQueue: jest.fn(), - clearQueue: jest.fn() -})) - const fetch = createFetch() -const { readQueue, writeQueue, clearQueue } = require('../src/queue-store') const { main } = require('../src/flush-worker') const PROXY = 'https://telemetry-proxy.example/api/v1/web/dx-excshell-1/telemetry' @@ -39,18 +32,13 @@ describe('flush-worker main()', () => { beforeEach(() => { origArgv = process.argv fetch.mockReset() - readQueue.mockClear() - writeQueue.mockClear() - clearQueue.mockClear() }) afterEach(() => { process.argv = origArgv }) - test('POSTs merged metrics and clears queue on success', async () => { - const queued = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'prerun' } }] - readQueue.mockReturnValue(queued) + test('POSTs batches from payload on success', async () => { fetch.mockResolvedValue({ ok: true }) process.argv = ['node', 'flush-worker.js', flushArg()] @@ -64,77 +52,42 @@ describe('flush-worker main()', () => { expect(opts.headers['Api-Key']).toBeUndefined() const posted = JSON.parse(opts.body) - expect(posted.batches[0].metrics).toHaveLength(2) - expect(posted.batches[0].metrics[0]).toEqual(queued[0]) - expect(posted.batches[0].metrics[1]).toEqual(METRIC) - - expect(clearQueue).toHaveBeenCalledTimes(1) - expect(writeQueue).not.toHaveBeenCalled() + expect(posted.batches).toHaveLength(1) + expect(posted.batches[0].metrics).toHaveLength(1) + expect(posted.batches[0].metrics[0]).toEqual(METRIC) }) - test('writes merged metrics to queue on fetch failure', async () => { - readQueue.mockReturnValue([]) + test('does not throw when fetch rejects', async () => { fetch.mockRejectedValue(new Error('network error')) process.argv = ['node', 'flush-worker.js', flushArg()] - await main() - - expect(writeQueue).toHaveBeenCalledTimes(1) - expect(writeQueue).toHaveBeenCalledWith([METRIC]) - expect(clearQueue).not.toHaveBeenCalled() + await expect(main()).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledTimes(1) }) - test('writes merged metrics to queue when fetch resolves with non-ok response', async () => { - readQueue.mockReturnValue([]) + test('does not throw when fetch resolves with non-ok response', async () => { fetch.mockResolvedValue({ ok: false, status: 503 }) process.argv = ['node', 'flush-worker.js', flushArg()] - await main() - - expect(writeQueue).toHaveBeenCalledTimes(1) - expect(writeQueue).toHaveBeenCalledWith([METRIC]) - expect(clearQueue).not.toHaveBeenCalled() + await expect(main()).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledTimes(1) }) - test('writes merged metrics to queue when fetch resolves with ok false and no status', async () => { - readQueue.mockReturnValue([]) + test('does not throw when fetch resolves with ok false and no status', async () => { fetch.mockResolvedValue({ ok: false }) process.argv = ['node', 'flush-worker.js', flushArg()] - await main() - - expect(writeQueue).toHaveBeenCalledTimes(1) - expect(writeQueue).toHaveBeenCalledWith([METRIC]) - expect(clearQueue).not.toHaveBeenCalled() + await expect(main()).resolves.toBeUndefined() }) - test('writes merged metrics to queue when fetch resolves with undefined response', async () => { - readQueue.mockReturnValue([]) + test('does not throw when fetch resolves with undefined response', async () => { fetch.mockResolvedValue(undefined) process.argv = ['node', 'flush-worker.js', flushArg()] - await main() - - expect(writeQueue).toHaveBeenCalledTimes(1) - expect(writeQueue).toHaveBeenCalledWith([METRIC]) - expect(clearQueue).not.toHaveBeenCalled() - }) - - test('merges empty queue with current event', async () => { - readQueue.mockReturnValue([]) - fetch.mockResolvedValue({ ok: true }) - - process.argv = ['node', 'flush-worker.js', flushArg()] - await main() - - const posted = JSON.parse(fetch.mock.calls[0][1].body) - expect(posted.batches[0].metrics).toHaveLength(1) - expect(posted.batches[0].metrics[0]).toEqual(METRIC) - expect(clearQueue).toHaveBeenCalledTimes(1) + await expect(main()).resolves.toBeUndefined() }) test('merges optional payload headers as overrides after defaults', async () => { - readQueue.mockReturnValue([]) fetch.mockResolvedValue({ ok: true }) process.argv = ['node', 'flush-worker.js', flushArg(BODY, { 'X-Custom-Proxy': 'unit-test' })] await main() @@ -150,14 +103,12 @@ describe('flush-worker main()', () => { process.argv = ['node', 'flush-worker.js'] await main() expect(fetch).not.toHaveBeenCalled() - expect(writeQueue).not.toHaveBeenCalled() }) test('returns silently when argv[2] is malformed JSON', async () => { process.argv = ['node', 'flush-worker.js', 'not-json{{{'] await main() expect(fetch).not.toHaveBeenCalled() - expect(writeQueue).not.toHaveBeenCalled() }) test('returns silently when postUrl is missing', async () => { @@ -166,8 +117,13 @@ describe('flush-worker main()', () => { expect(fetch).not.toHaveBeenCalled() }) + test('returns silently when body is not a JSON array', async () => { + process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: '{"foo":1}', postUrl: PROXY })] + await main() + expect(fetch).not.toHaveBeenCalled() + }) + test('uses default headers when headers omitted from payload', async () => { - readQueue.mockReturnValue([]) fetch.mockResolvedValue({ ok: true }) process.argv = ['node', 'flush-worker.js', JSON.stringify({ body: BODY, postUrl: PROXY })] await main() diff --git a/test/hooks.test.js b/test/hooks.test.js index c8f98c0..8de8270 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -22,7 +22,6 @@ jest.mock('child_process', () => ({ const fetch = createFetch() const { spawn } = require('child_process') -const queueStore = require('../src/queue-store') const telemetryLib = require('../src/telemetry-lib') const mockPackageJson = { @@ -32,19 +31,10 @@ const mockPackageJson = { } describe('hook interfaces', () => { - beforeAll(() => { - jest.spyOn(queueStore, 'appendToQueue').mockImplementation(() => {}) - }) - - afterAll(() => { - queueStore.appendToQueue.mockRestore() - }) - beforeEach(() => { fetch.mockReset() spawn.mockClear() config.get.mockReset() - queueStore.appendToQueue.mockClear() }) test('command-error', async () => { @@ -54,8 +44,11 @@ describe('hook interfaces', () => { expect(typeof hook).toBe('function') await hook({ message: 'msg' }) expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"command-error"') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error', 'postrun']) }) test('command-not-found', async () => { @@ -65,8 +58,11 @@ describe('hook interfaces', () => { expect(typeof hook).toBe('function') await hook({ id: 'id' }) expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"command-not-found"') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayloadNf = JSON.parse(spawn.mock.calls[0][1][1]) + const bodyNf = JSON.parse(flushPayloadNf.body) + expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found', 'postrun']) }) /** @@ -83,9 +79,12 @@ describe('hook interfaces', () => { await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: [] }) expect(inquirer.prompt).toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-prompt"') - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('accepted') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayloadAcc = JSON.parse(spawn.mock.calls[0][1][1]) + const bodyAcc = JSON.parse(flushPayloadAcc.body) + expect(bodyAcc[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-prompt', 'postrun']) + expect(bodyAcc[0].metrics[0].attributes.eventData).toBe('accepted') process.env = preEnv }) @@ -167,9 +166,13 @@ describe('hook interfaces', () => { await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: ['--verbose'] }) expect(inquirer.prompt).toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-prompt"') - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('declined') + telemetryLib.enable() + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayloadDec = JSON.parse(spawn.mock.calls[0][1][1]) + const bodyDec = JSON.parse(flushPayloadDec.body) + expect(bodyDec[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-prompt', 'postrun']) + expect(bodyDec[0].metrics[0].attributes.eventData).toBe('declined') process.env = preEnv }) @@ -184,8 +187,11 @@ describe('hook interfaces', () => { await hook({ data: { feature: 'x' } }) expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - expect(JSON.stringify(queueStore.appendToQueue.mock.calls[0][0])).toContain('"eventType":"telemetry-custom-event"') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayloadCe = JSON.parse(spawn.mock.calls[0][1][1]) + const bodyCe = JSON.parse(flushPayloadCe.body) + expect(bodyCe[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-custom-event', 'postrun']) }) test('postrun', async () => { diff --git a/test/queue-store.test.js b/test/queue-store.test.js deleted file mode 100644 index 743aa5d..0000000 --- a/test/queue-store.test.js +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright 2026 Adobe Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const path = require('path') -const os = require('os') - -jest.mock('fs') -const fs = require('fs') - -const queueStore = require('../src/queue-store') - -const DEFAULT_QUEUE_PATH = path.join(os.homedir(), '.config', 'aio', '.telemetry-queue.json') -const XDG_QUEUE_PATH = path.join('/xdg-home', 'aio', '.telemetry-queue.json') - -describe('queue-store', () => { - beforeEach(() => { - jest.resetAllMocks() - delete process.env.XDG_CONFIG_HOME - }) - - describe('getQueuePath', () => { - test('returns path under ~/.config/aio when XDG_CONFIG_HOME is not set', () => { - expect(queueStore.getQueuePath()).toBe(DEFAULT_QUEUE_PATH) - }) - - test('honours XDG_CONFIG_HOME when set', () => { - process.env.XDG_CONFIG_HOME = '/xdg-home' - expect(queueStore.getQueuePath()).toBe(XDG_QUEUE_PATH) - delete process.env.XDG_CONFIG_HOME - }) - }) - - describe('readQueue', () => { - test('returns parsed array when file contains valid JSON', () => { - const items = [{ name: 'aio.cli.telemetry', value: 1 }] - fs.readFileSync.mockReturnValue(JSON.stringify(items)) - expect(queueStore.readQueue()).toEqual(items) - }) - - test('returns empty array when file does not exist', () => { - fs.readFileSync.mockImplementation(() => { throw new Error('ENOENT') }) - expect(queueStore.readQueue()).toEqual([]) - }) - - test('returns empty array when file contains invalid JSON', () => { - fs.readFileSync.mockReturnValue('not-json{{{') - expect(queueStore.readQueue()).toEqual([]) - }) - - test('returns empty array when parsed value is not an array', () => { - fs.readFileSync.mockReturnValue(JSON.stringify({ foo: 'bar' })) - expect(queueStore.readQueue()).toEqual([]) - }) - - test('truncates oversized file to MAX_QUEUE_METRICS and rewrites disk', () => { - const oversized = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 5 }, (_, i) => ({ i })) - fs.readFileSync.mockReturnValue(JSON.stringify(oversized)) - const result = queueStore.readQueue() - expect(result).toHaveLength(queueStore.MAX_QUEUE_METRICS) - expect(result[0].i).toBe(5) - expect(result[queueStore.MAX_QUEUE_METRICS - 1].i).toBe(queueStore.MAX_QUEUE_METRICS + 4) - expect(fs.writeFileSync).toHaveBeenCalledWith( - DEFAULT_QUEUE_PATH, - JSON.stringify(result), - 'utf8' - ) - }) - - test('returns truncated queue when shrink rewrite fails', () => { - const oversized = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 3 }, (_, i) => ({ i })) - fs.readFileSync.mockReturnValue(JSON.stringify(oversized)) - fs.writeFileSync.mockImplementation(() => { - throw new Error('EACCES') - }) - const result = queueStore.readQueue() - expect(result).toHaveLength(queueStore.MAX_QUEUE_METRICS) - expect(result[0].i).toBe(3) - }) - }) - - describe('appendToQueue', () => { - test('merges new metrics after existing file contents', () => { - const existing = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'a' } }] - const incoming = [{ name: 'aio.cli.telemetry', value: 1, attributes: { eventType: 'b' } }] - fs.readFileSync.mockReturnValue(JSON.stringify(existing)) - queueStore.appendToQueue(incoming) - expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(DEFAULT_QUEUE_PATH), { recursive: true }) - expect(fs.writeFileSync).toHaveBeenCalledWith( - DEFAULT_QUEUE_PATH, - JSON.stringify([...existing, ...incoming]), - 'utf8' - ) - }) - - test('truncates when merged queue would exceed MAX_QUEUE_METRICS', () => { - const existing = Array.from({ length: queueStore.MAX_QUEUE_METRICS }, (_, i) => ({ tag: 'e', i })) - const incoming = [{ tag: 'n', x: 1 }, { tag: 'n', x: 2 }] - fs.readFileSync.mockReturnValue(JSON.stringify(existing)) - queueStore.appendToQueue(incoming) - const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]) - expect(written).toHaveLength(queueStore.MAX_QUEUE_METRICS) - expect(written[queueStore.MAX_QUEUE_METRICS - 2]).toEqual(incoming[0]) - expect(written[queueStore.MAX_QUEUE_METRICS - 1]).toEqual(incoming[1]) - }) - - test('writes only new metrics when read fails', () => { - fs.readFileSync.mockImplementation(() => { throw new Error('ENOENT') }) - const incoming = [{ name: 'aio.cli.telemetry', value: 1 }] - queueStore.appendToQueue(incoming) - expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify(incoming), 'utf8') - }) - - test('no-op when newItems is empty or not an array', () => { - fs.readFileSync.mockReturnValue('[]') - queueStore.appendToQueue([]) - queueStore.appendToQueue(null) - expect(fs.writeFileSync).not.toHaveBeenCalled() - }) - }) - - describe('writeQueue', () => { - test('creates directory and writes items as JSON', () => { - const items = [{ name: 'aio.cli.telemetry', value: 1 }] - queueStore.writeQueue(items) - expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(DEFAULT_QUEUE_PATH), { recursive: true }) - expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify(items), 'utf8') - }) - - test('writeQueue keeps at most MAX_QUEUE_METRICS entries (newest)', () => { - const items = Array.from({ length: queueStore.MAX_QUEUE_METRICS + 42 }, (_, i) => ({ i })) - const expected = items.slice(-queueStore.MAX_QUEUE_METRICS) - queueStore.writeQueue(items) - expect(fs.writeFileSync).toHaveBeenCalledWith( - DEFAULT_QUEUE_PATH, - JSON.stringify(expected), - 'utf8' - ) - }) - - test('writeQueue persists empty array when items is not an array', () => { - queueStore.writeQueue(null) - expect(fs.writeFileSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH, JSON.stringify([]), 'utf8') - }) - - test('silently ignores write errors', () => { - fs.mkdirSync.mockImplementation(() => { throw new Error('EACCES') }) - expect(() => queueStore.writeQueue([])).not.toThrow() - }) - }) - - describe('clearQueue', () => { - test('deletes the queue file', () => { - queueStore.clearQueue() - expect(fs.unlinkSync).toHaveBeenCalledWith(DEFAULT_QUEUE_PATH) - }) - - test('silently ignores errors when file does not exist', () => { - fs.unlinkSync.mockImplementation(() => { throw new Error('ENOENT') }) - expect(() => queueStore.clearQueue()).not.toThrow() - }) - }) -}) diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 9d0f574..c663c7d 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -13,7 +13,6 @@ const { createFetch } = require('@adobe/aio-lib-core-networking') const telemetryLib = require('../src/telemetry-lib') const config = require('@adobe/aio-lib-core-config') -const queueStore = require('../src/queue-store') jest.mock('@adobe/aio-lib-core-config') jest.mock('child_process', () => ({ @@ -24,19 +23,10 @@ const fetch = createFetch() const { spawn } = require('child_process') describe('telemetry-lib', () => { - beforeAll(() => { - jest.spyOn(queueStore, 'appendToQueue').mockImplementation(() => {}) - }) - - afterAll(() => { - queueStore.appendToQueue.mockRestore() - }) - beforeEach(() => { jest.resetModules() fetch.mockReset() spawn.mockClear() - queueStore.appendToQueue.mockClear() }) test('exports messages', async () => { @@ -66,17 +56,18 @@ describe('telemetry-lib', () => { await expect(telemetryLib.trackEvent('postrun')).resolves.toBeUndefined() }) - test('uses client id from config when queueing non-postrun events', async () => { + test('buffers non-postrun until postrun merges one batch', async () => { config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest2', {}) + telemetryLib.init('a@4', 'binBuf', {}) await telemetryLib.trackEvent('telemetry-custom-event') - expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.clientId') - expect(config.get).toHaveBeenCalledWith('binTest2-cli-telemetry.optOut', 'global') expect(spawn).not.toHaveBeenCalled() - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - const appended = queueStore.appendToQueue.mock.calls[0][0] - expect(appended).toHaveLength(1) - expect(JSON.stringify(appended[0])).toContain('"clientId":"clientidxyz"') + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(body[0].metrics).toHaveLength(2) + expect(body[0].metrics[0].attributes.eventType).toBe('telemetry-custom-event') + expect(body[0].metrics[1].attributes.eventType).toBe('postrun') }) test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => { @@ -186,24 +177,27 @@ describe('telemetry-lib', () => { else delete process.env.AIO_TELEMETRY_DISABLED }) - test('trackEvent does not queue when AIO_TELEMETRY_DISABLED is set for non-postrun', async () => { + test('trackEvent does not flush when AIO_TELEMETRY_DISABLED is set for non-postrun', async () => { const orig = process.env.AIO_TELEMETRY_DISABLED process.env.AIO_TELEMETRY_DISABLED = '1' config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binNoQueue', {}) await telemetryLib.trackEvent('command-error', { message: 'x' }) - expect(queueStore.appendToQueue).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig else delete process.env.AIO_TELEMETRY_DISABLED }) - test('string eventData is stored without extra JSON quotes when queueing', async () => { + test('string eventData is stored without extra JSON quotes in flush payload', async () => { config.get.mockReturnValue('clientidxyz') telemetryLib.init('a@4', 'binStrEd', {}) await telemetryLib.trackEvent('telemetry-prompt', 'accepted') - expect(queueStore.appendToQueue).toHaveBeenCalledTimes(1) - const metric = queueStore.appendToQueue.mock.calls[0][0][0] + expect(spawn).not.toHaveBeenCalled() + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + const metric = body[0].metrics[0] expect(metric.attributes.eventData).toBe('accepted') expect(metric.attributes.eventData).not.toMatch(/^"/) }) From dfa8573fa6f47447eb662af2cd47b2b3afb01742 Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 12:18:57 -0700 Subject: [PATCH 23/31] accept disabled=yes|true|1, do not override flush worker env --- README.md | 6 +-- src/telemetry-lib.js | 13 +++-- test/telemetry-lib.test.js | 101 ++++++++++++++++++++++++------------- 3 files changed, 78 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3450f50..614d421 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,14 @@ The resolved URL is passed to the flush worker on each telemetry send; it applie ## Opting out via environment variable -Telemetry is suppressed when `AIO_TELEMETRY_DISABLED` is **set** to a **non-empty** string. The plugin checks `process.env.AIO_TELEMETRY_DISABLED` as a JavaScript value: unset or `''` does not disable; any other string is truthy and disables telemetry. - -Because environment variables are always strings in Node.js, this is **not** boolean parsing: `AIO_TELEMETRY_DISABLED=0`, `=false`, or `=no` still disable telemetry (the values are the strings `"0"`, `"false"`, `"no"`, all of which are truthy). To run with telemetry allowed, **unset** the variable (or use an empty value if your environment exposes `''`). +Telemetry is suppressed when `AIO_TELEMETRY_DISABLED` is set to one of **`true`**, **`1`**, or **`yes`** (exact match; case-sensitive). Other values such as `0`, `false`, `no`, or an empty string do **not** disable telemetry via this variable. This does not change the persisted opt-in state. Useful for CI pipelines and scripted environments. ```sh +AIO_TELEMETRY_DISABLED=true aio app deploy AIO_TELEMETRY_DISABLED=1 aio app deploy +AIO_TELEMETRY_DISABLED=yes aio app deploy ``` ## Flush architecture diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 891f249..c0992ca 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -20,6 +20,11 @@ const debug = require('debug')('aio-telemetry:telemetry-lib') /** Adobe I/O App Builder web action that forwards CLI metrics to New Relic (ingest key stays server-side). */ const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy-stage.adobeio-static.net/api/v1/web/dx-excshell-1/telemetry' +/** @returns {boolean} Whether `AIO_TELEMETRY_DISABLED` opts out (only the literal string `"true"`). */ +function isEnvTelemetryDisabled () { + return ['true', '1', 'yes'].includes(process.env.AIO_TELEMETRY_DISABLED) +} + let isDisabledForCommand = false /** Metrics for non-`postrun` events in the current command; merged into one POST on `postrun`. */ @@ -186,7 +191,7 @@ async function trackEvent (eventType, rawEventData = {}) { const eventData = resolveEventData(eventType, rawEventData) - const optedOut = isDisabledForCommand || process.env.AIO_TELEMETRY_DISABLED || config.get(`${configKey}.optOut`, 'global') === true + const optedOut = isDisabledForCommand || isEnvTelemetryDisabled() || config.get(`${configKey}.optOut`, 'global') === true const willSend = !optedOut debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend) @@ -242,8 +247,8 @@ async function trackEvent (eventType, rawEventData = {}) { ...(Object.keys(extraFetchHeaders).length > 0 && { headers: { ...extraFetchHeaders } }) }) try { + // Omit `env`: child inherits `process.env` (proxy / TLS vars for fetch in the worker). const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], { - env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' }, detached: true, stdio: 'ignore' }) @@ -287,13 +292,13 @@ module.exports = { config.set(`${configKey}.optOut`, true) }, isEnabled: () => { - return !isDisabledForCommand && !process.env.AIO_TELEMETRY_DISABLED && config.get(`${configKey}.optOut`, 'global') === false + return !isDisabledForCommand && !isEnvTelemetryDisabled() && config.get(`${configKey}.optOut`, 'global') === false }, disableForCommand: () => { isDisabledForCommand = true }, isNull: () => { - return !process.env.AIO_TELEMETRY_DISABLED && config.get(`${configKey}.optOut`, 'global') === undefined + return !isEnvTelemetryDisabled() && config.get(`${configKey}.optOut`, 'global') === undefined }, trackEvent, trackPrerun, diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index c663c7d..cd77de1 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -166,27 +166,51 @@ describe('telemetry-lib', () => { else delete process.env.AIO_TELEMETRY_POST_URL }) - test('trackEvent does not post when AIO_TELEMETRY_DISABLED is set', async () => { - const orig = process.env.AIO_TELEMETRY_DISABLED - process.env.AIO_TELEMETRY_DISABLED = '1' - config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binTest', {}) - await telemetryLib.trackEvent('postrun') - expect(spawn).not.toHaveBeenCalled() - if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig - else delete process.env.AIO_TELEMETRY_DISABLED - }) - - test('trackEvent does not flush when AIO_TELEMETRY_DISABLED is set for non-postrun', async () => { - const orig = process.env.AIO_TELEMETRY_DISABLED - process.env.AIO_TELEMETRY_DISABLED = '1' - config.get.mockReturnValue('clientidxyz') - telemetryLib.init('a@4', 'binNoQueue', {}) - await telemetryLib.trackEvent('command-error', { message: 'x' }) - expect(spawn).not.toHaveBeenCalled() - if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig - else delete process.env.AIO_TELEMETRY_DISABLED - }) + test.each(['true', '1', 'yes'])( + 'trackEvent does not post when AIO_TELEMETRY_DISABLED is %s', + async (value) => { + const orig = process.env.AIO_TELEMETRY_DISABLED + process.env.AIO_TELEMETRY_DISABLED = value + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binTest', {}) + await telemetryLib.trackEvent('postrun') + expect(spawn).not.toHaveBeenCalled() + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED + } + ) + + test.each(['0', 'false', 'no', ''])( + 'trackEvent posts when AIO_TELEMETRY_DISABLED is %j', + async (value) => { + const orig = process.env.AIO_TELEMETRY_DISABLED + if (value === '') { + delete process.env.AIO_TELEMETRY_DISABLED + } else { + process.env.AIO_TELEMETRY_DISABLED = value + } + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binEnvNotDisabled', {}) + await telemetryLib.trackEvent('postrun') + expect(spawn).toHaveBeenCalled() + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED + } + ) + + test.each(['true', '1', 'yes'])( + 'trackEvent does not buffer when AIO_TELEMETRY_DISABLED is %s for non-postrun', + async (value) => { + const orig = process.env.AIO_TELEMETRY_DISABLED + process.env.AIO_TELEMETRY_DISABLED = value + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binNoQueue', {}) + await telemetryLib.trackEvent('command-error', { message: 'x' }) + expect(spawn).not.toHaveBeenCalled() + if (orig !== undefined) process.env.AIO_TELEMETRY_DISABLED = orig + else delete process.env.AIO_TELEMETRY_DISABLED + } + ) test('string eventData is stored without extra JSON quotes in flush payload', async () => { config.get.mockReturnValue('clientidxyz') @@ -401,8 +425,6 @@ describe('AIO_TELEMETRY_DISABLED', () => { beforeEach(() => { orig = process.env.AIO_TELEMETRY_DISABLED - process.env.AIO_TELEMETRY_DISABLED = '1' - telemetryLib.init('a@4', 'binTest', {}) }) afterEach(() => { @@ -410,16 +432,25 @@ describe('AIO_TELEMETRY_DISABLED', () => { else delete process.env.AIO_TELEMETRY_DISABLED }) - test('isEnabled returns false when AIO_TELEMETRY_DISABLED is set', () => { - // config.get would return false (opted in), but the env var should override - const config = require('@adobe/aio-lib-core-config') - config.get.mockReturnValue(false) - expect(telemetryLib.isEnabled()).toBe(false) - }) - - test('isNull returns false when AIO_TELEMETRY_DISABLED is set', () => { - const config = require('@adobe/aio-lib-core-config') - config.get.mockReturnValue(undefined) - expect(telemetryLib.isNull()).toBe(false) - }) + test.each(['true', '1', 'yes'])( + 'isEnabled returns false when AIO_TELEMETRY_DISABLED is %s', + (value) => { + process.env.AIO_TELEMETRY_DISABLED = value + telemetryLib.init('a@4', 'binTest', {}) + const config = require('@adobe/aio-lib-core-config') + config.get.mockReturnValue(false) + expect(telemetryLib.isEnabled()).toBe(false) + } + ) + + test.each(['true', '1', 'yes'])( + 'isNull returns false when AIO_TELEMETRY_DISABLED is %s', + (value) => { + process.env.AIO_TELEMETRY_DISABLED = value + telemetryLib.init('a@4', 'binTest', {}) + const config = require('@adobe/aio-lib-core-config') + config.get.mockReturnValue(undefined) + expect(telemetryLib.isNull()).toBe(false) + } + ) }) From 16cb9d9a66702272857ae666e821ec6566c654cf Mon Sep 17 00:00:00 2001 From: Jesse MacFadyen Date: Thu, 14 May 2026 12:42:43 -0700 Subject: [PATCH 24/31] Removing the redundant pattern: fetchConfig mimicked an HTTP request but the parent never calls fetch --- src/telemetry-lib.js | 60 ++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index c0992ca..51c83c0 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -94,14 +94,7 @@ const osNameVersion = `${os.type()} ${os.release()}` let rootCliVersion = '?' let prerunEvent = { flags: [] } -/** Built-in request headers; the flush worker subprocess applies the same defaults for its POST. */ -const DEFAULT_FETCH_HEADERS = { - 'Content-Type': 'application/json' -} - -/** @type {Record} Default headers for the metric payload (flush worker merges {@link extraFetchHeaders}). */ -const fetchHeaders = { ...DEFAULT_FETCH_HEADERS } -/** @type {Record} Host-only headers from `aioTelemetry.fetchHeaders` (passed to flush worker as overrides, not defaults). */ +/** @type {Record} Host-only headers from `aioTelemetry.fetchHeaders` (passed to flush worker as overrides). */ let extraFetchHeaders = {} /** @type {string} Resolved proxy URL (defaults at module load; init may override). */ let postUrl = DEFAULT_TELEMETRY_POST_URL @@ -202,42 +195,33 @@ async function trackEvent (eventType, rawEventData = {}) { const clientId = getClientId() const timestamp = Date.now() const invocationContext = getInvocationContext() - const fetchConfig = { - method: 'POST', - headers: fetchHeaders, - body: JSON.stringify([{ - metrics: [{ - name: 'aio.cli.telemetry', - type: 'gauge', - value: 1, - // id: Math.floor(timestamp * Math.random()), - timestamp, - attributes: { - eventType, - eventData: formatEventDataAttribute(eventData), - cliVersion: rootCliVersion, - clientId, - command: prerunEvent.command, - commandDuration: timestamp - prerunEvent.start, - commandFlags: prerunEvent.flags.toString(), - commandSuccess: eventType !== 'command-error', - nodeVersion: process.version, - osNameVersion, - invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human', - agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown' - } - }] - }]) + const metric = { + name: 'aio.cli.telemetry', + type: 'gauge', + value: 1, + timestamp, + attributes: { + eventType, + eventData: formatEventDataAttribute(eventData), + cliVersion: rootCliVersion, + clientId, + command: prerunEvent.command, + commandDuration: timestamp - prerunEvent.start, + commandFlags: prerunEvent.flags.toString(), + commandSuccess: eventType !== 'command-error', + nodeVersion: process.version, + osNameVersion, + invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human', + agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown' + } } - const batch = JSON.parse(fetchConfig.body) - const metricsThisEvent = batch[0].metrics if (eventType !== 'postrun') { - pendingCommandMetrics.push(...metricsThisEvent) + pendingCommandMetrics.push(metric) return } - const mergedMetrics = [...pendingCommandMetrics, ...metricsThisEvent] + const mergedMetrics = [...pendingCommandMetrics, metric] pendingCommandMetrics.length = 0 const mergedBody = JSON.stringify([{ metrics: mergedMetrics }]) From e3387c78825f37aadaf170cedf8a932e0abf2324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Mon, 8 Jun 2026 10:12:34 -0700 Subject: [PATCH 25/31] feat(telemetry): make telemetry opt-out with a one-time notice --- README.md | 10 ++--- package.json | 3 +- src/hooks/init.js | 13 +++--- src/telemetry-lib.js | 39 ++++++----------- test/hooks.test.js | 86 ++++++++++++-------------------------- test/index.test.js | 1 - test/telemetry-lib.test.js | 13 ++++++ 7 files changed, 63 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 614d421..cd86ede 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ When this plugin is hosted by different CLIs: - `aioTelemetry` (optional object in the root `package.json` of the **host CLI** — the same `pjson` oclif passes into the init hook): - `postUrl` (optional string): HTTPS URL of the telemetry proxy that receives POSTed metric batches. Use this when your CLI should send telemetry to a different App Builder action or gateway than the plugin default. - `fetchHeaders`: Optional extra headers merged into telemetry requests (`Content-Type` is always set) - - `productPrivacyPolicyLink`: A link to display to users when prompting to opt in -- `productName`: How to refer to the CLI when the user is prompted to enable telemetry (from `displayName` or `name` in `package.json`) + - `productPrivacyPolicyLink`: A link shown in the one-time telemetry notice (what is collected and how to opt out) +- `productName`: How to refer to the CLI in the telemetry notice (from `displayName` or `name` in `package.json`) - `productBin`: Shown in help text (from `bin` in `package.json`; if several bins exist, the first is used). Example: run `${productBin} telemetry on` ### Overriding the telemetry POST URL @@ -80,7 +80,7 @@ The resolved URL is passed to the flush worker on each telemetry send; it applie Telemetry is suppressed when `AIO_TELEMETRY_DISABLED` is set to one of **`true`**, **`1`**, or **`yes`** (exact match; case-sensitive). Other values such as `0`, `false`, `no`, or an empty string do **not** disable telemetry via this variable. -This does not change the persisted opt-in state. Useful for CI pipelines and scripted environments. +This does not change the persisted opt-out state. Useful for CI pipelines and scripted environments. ```sh AIO_TELEMETRY_DISABLED=true aio app deploy @@ -94,7 +94,7 @@ Telemetry is **best-effort**: events are not persisted when the proxy is down or On **`postrun`**, any in-memory metrics from earlier hooks in the same command are merged with the `postrun` metric and the combined batch is handed off to a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI spawns the worker and immediately `unref()`s it, so the CLI can exit without waiting for the HTTP POST. If the POST fails (network error or non-2xx response), the batch is dropped; telemetry must not block or slow normal CLI use. -Non-`postrun` events (for example `command-error`, `telemetry-prompt`) are held in an **in-memory buffer** until that flush. If the process exits before `postrun` (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session). +Non-`postrun` events (for example `command-error`, `telemetry-notice`) are held in an **in-memory buffer** until that flush. If the process exits before `postrun` (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session). ## Agent detection @@ -122,7 +122,7 @@ To opt into agent tracking without setting a tool-specific variable, set `AIO_IN ## POST data -The `eventData` attribute is always a string. Objects and arrays are stored as a JSON text (e.g. `"{}"`, `"{\"message\":\"…\"}"`). String payloads (such as telemetry prompt outcomes `accepted` / `declined`) are stored as that plain text without an extra layer of JSON quoting. Numbers and booleans use their usual string forms (`"0"`, `"false"`). +The `eventData` attribute is always a string. Objects and arrays are stored as a JSON text (e.g. `"{}"`, `"{\"message\":\"…\"}"`). String payloads (such as the telemetry notice outcome `shown`) are stored as that plain text without an extra layer of JSON quoting. Numbers and booleans use their usual string forms (`"0"`, `"false"`). Example shape of the metric payload: diff --git a/package.json b/package.json index a773fd3..c0fd77b 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "@adobe/aio-lib-core-networking": "^5.0.4", "@oclif/core": "^4", "ci-info": "^4.0.0", - "debug": "^4.1.1", - "inquirer": "^8.2.1" + "debug": "^4.1.1" }, "devDependencies": { "@adobe/eslint-config-aio-lib-config": "5.0.0", diff --git a/src/hooks/init.js b/src/hooks/init.js index f1d0910..c3e68b3 100644 --- a/src/hooks/init.js +++ b/src/hooks/init.js @@ -37,17 +37,16 @@ module.exports = async function (opts) { opts.argv.filter(arg => arg.indexOf('-') === 0).join(','), global.prerunTimer) - // init event does not post telemetry, it stores some info that will be used later - // this will prompt to optIn/Out if telemetry.optIn is undefined + // Telemetry is opt-out (on by default). On the first run we show a one-time, + // non-blocking notice instead of an opt-in prompt. isNull() is true only until the + // notice records the default, so this shows once. if ((opts.argv.indexOf('--no-telemetry') < 0) && !inCI && telemetryLib.isNull()) { - // let's ask! - // unfortunately the `oclif-dev readme` run by prepack fires this event, which hangs CI - // Also we don't prompt for telemetry if the first command is a telemetry command because they - // are probably setting it on or off already + // skip in CI (handled above) and when oclif-dev readme runs (it fires this event); + // also skip for telemetry commands, where the user is already setting state. if (['readme', 'telemetry'].indexOf(opts.id) < 0) { - return telemetryLib.prompt(productName, binName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink) + telemetryLib.notice(productName, binName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink) } } } diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 51c83c0..e4ca2c8 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -14,7 +14,6 @@ const path = require('path') const os = require('os') const config = require('@adobe/aio-lib-core-config') -const inquirer = require('inquirer') const debug = require('debug')('aio-telemetry:telemetry-lib') /** Adobe I/O App Builder web action that forwards CLI metrics to New Relic (ingest key stays server-side). */ @@ -120,6 +119,12 @@ const getOnMessage = (productName, binName) => { const getOffMessage = (binName) => { return `\nTelemetry is off.\nIf you would like to turn telemetry on, simply run \`${binName} telemetry on\`` } +const getNoticeMessage = (productName, binName, privacyPolicyLink) => { + return `${productName} collects anonymous usage data to help us improve our products. ` + + 'Telemetry is on by default; read what we collect and how it is used here:\n' + + ` ${privacyPolicyLink || defaultPrivacyPolicyLink}\n` + + `To opt out, run \`${binName} telemetry off\` (or set AIO_TELEMETRY_DISABLED=true).` +} /** * Builds the value stored in the metric `eventData` attribute. For `postrun`, an empty object is @@ -276,7 +281,7 @@ module.exports = { config.set(`${configKey}.optOut`, true) }, isEnabled: () => { - return !isDisabledForCommand && !isEnvTelemetryDisabled() && config.get(`${configKey}.optOut`, 'global') === false + return !isDisabledForCommand && !isEnvTelemetryDisabled() && config.get(`${configKey}.optOut`, 'global') !== true }, disableForCommand: () => { isDisabledForCommand = true @@ -296,30 +301,10 @@ module.exports = { }, getOnMessage, getOffMessage, - prompt: async (productName, binName, privacyPolicyLink) => { - console.log(` - How you use ${productName} provides us with important data that we can use - to make our products better. Please read our guide for more information on - the data we anonymously collect, and how we use it. - ${privacyPolicyLink || defaultPrivacyPolicyLink} - `) - - const response = await inquirer.prompt([{ - name: 'accept', - type: 'confirm', - message: `Would you like to allow ${productName} to collect anonymous usage data?` - }]) - if (response.accept) { - config.set(`${configKey}.optOut`, false) - console.log(getOnMessage(productName, binName)) - trackEvent('telemetry-prompt', 'accepted') - } else { - // we will set optOut to true after tracking this one event - // todo: check if tty error - config.set(`${configKey}.optOut`, false) - console.log(getOffMessage(binName)) - trackEvent('telemetry-prompt', 'declined') - config.set(`${configKey}.optOut`, true) - } + getNoticeMessage, + notice: (productName, binName, privacyPolicyLink) => { + console.log(getNoticeMessage(productName, binName, privacyPolicyLink)) + config.set(`${configKey}.optOut`, false) + trackEvent('telemetry-notice', 'shown') } } diff --git a/test/hooks.test.js b/test/hooks.test.js index 8de8270..aa24115 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -11,10 +11,8 @@ */ const { createFetch } = require('@adobe/aio-lib-core-networking') -const inquirer = require('inquirer') const config = require('@adobe/aio-lib-core-config') -jest.mock('inquirer') jest.mock('@adobe/aio-lib-core-config') jest.mock('child_process', () => ({ spawn: jest.fn(() => ({ unref: jest.fn() })) @@ -31,10 +29,16 @@ const mockPackageJson = { } describe('hook interfaces', () => { + let noticeSpy beforeEach(() => { fetch.mockReset() spawn.mockClear() config.get.mockReset() + config.set.mockClear() + noticeSpy = jest.spyOn(telemetryLib, 'notice') + }) + afterEach(() => { + noticeSpy.mockRestore() }) test('command-error', async () => { @@ -65,114 +69,77 @@ describe('hook interfaces', () => { expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found', 'postrun']) }) - /** - * Should prompt when config.get(optOut) returns undefined - * post results - */ - test('init prompt accept:true', async () => { + test('init shows one-time notice on first run', async () => { const preEnv = process.env process.env = { ...preEnv, CI: undefined, GITHUB_ACTIONS: undefined } const hook = require('../src/hooks/init') expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: [] }) - expect(inquirer.prompt).toHaveBeenCalled() + expect(noticeSpy).toHaveBeenCalled() + expect(config.set).toHaveBeenCalledWith('aio-cli-telemetry.optOut', false) expect(spawn).not.toHaveBeenCalled() await telemetryLib.trackEvent('postrun') expect(spawn).toHaveBeenCalledTimes(1) const flushPayloadAcc = JSON.parse(spawn.mock.calls[0][1][1]) const bodyAcc = JSON.parse(flushPayloadAcc.body) - expect(bodyAcc[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-prompt', 'postrun']) - expect(bodyAcc[0].metrics[0].attributes.eventData).toBe('accepted') + expect(bodyAcc[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-notice', 'postrun']) + expect(bodyAcc[0].metrics[0].attributes.eventData).toBe('shown') process.env = preEnv }) - test('init prompt - full coverage when run by gh actions', async () => { + test('init - no notice for telemetry commands', async () => { const preEnv = process.env process.env = { ...preEnv, CI: undefined, GITHUB_ACTIONS: undefined } const hook = require('../src/hooks/init') expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'telemetry', config: { name: 'name', version: '0.0.1' }, argv: [] }) - expect(inquirer.prompt).not.toHaveBeenCalled() + expect(noticeSpy).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() process.env = preEnv }) - test('init prompt - dont ask for telemetry for telemetry commands', async () => { - const hook = require('../src/hooks/init') - expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) - config.get = jest.fn().mockReturnValue(undefined) - await hook({ id: 'telemetry', config: { name: 'name', version: '0.0.1' }, argv: [] }) - expect(inquirer.prompt).not.toHaveBeenCalled() - expect(spawn).not.toHaveBeenCalled() - }) - - test('init prompt - dont run when oclif is generating readme', async () => { - const hook = require('../src/hooks/init') - expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) - config.get = jest.fn().mockReturnValue(undefined) - await hook({ id: 'readme', config: { name: 'name', version: '0.0.1' }, argv: [] }) - expect(inquirer.prompt).not.toHaveBeenCalled() - expect(spawn).not.toHaveBeenCalled() - }) - - test('init prompt - dont run when oclif is generating readme and CI is off', async () => { + test('init - no notice when oclif is generating readme', async () => { const preEnv = process.env - process.env = { ...preEnv, CI: undefined } + process.env = { ...preEnv, CI: undefined, GITHUB_ACTIONS: undefined } const hook = require('../src/hooks/init') expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: true }) config.get = jest.fn().mockReturnValue(undefined) await hook({ id: 'readme', config: { name: 'name', version: '0.0.1' }, argv: [] }) - expect(inquirer.prompt).not.toHaveBeenCalled() + expect(noticeSpy).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() process.env = preEnv }) - test('no prompt when process.env.CI', async () => { + test('init - no notice when process.env.CI', async () => { const preEnv = process.env process.env = { ...preEnv, CI: 'true' } let hook jest.isolateModules(() => { hook = require('../src/hooks/init') }) - expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: false }) config.get = jest.fn().mockReturnValue(undefined) - expect(inquirer.prompt).not.toHaveBeenCalled() await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--verbose'] }) + expect(noticeSpy).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() - expect(inquirer.prompt).not.toHaveBeenCalled() process.env = preEnv }) /** - * Should prompt when config.get(optOut) returns undefined - * should still post after prompt even though it is declined, this is the last post + * When the user has already chosen a state (optOut defined), isNull() is false, + * so the notice is not shown again. */ - test('init prompt accept:false', async () => { + test('init - no notice when telemetry state already set', async () => { const preEnv = process.env process.env = { ...preEnv, CI: undefined, GITHUB_ACTIONS: undefined } const hook = require('../src/hooks/init') expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn().mockResolvedValue({ accept: false }) - config.get = jest.fn().mockReturnValue(undefined) - await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: ['--verbose'] }) - expect(inquirer.prompt).toHaveBeenCalled() + config.get = jest.fn().mockReturnValue(false) // optOut already set -> isNull() false + await hook({ config: { name: 'name', version: '0.0.1', pjson: mockPackageJson }, argv: [] }) + expect(noticeSpy).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() - telemetryLib.enable() - await telemetryLib.trackEvent('postrun') - expect(spawn).toHaveBeenCalledTimes(1) - const flushPayloadDec = JSON.parse(spawn.mock.calls[0][1][1]) - const bodyDec = JSON.parse(flushPayloadDec.body) - expect(bodyDec[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-prompt', 'postrun']) - expect(bodyDec[0].metrics[0].attributes.eventData).toBe('declined') process.env = preEnv }) @@ -210,13 +177,12 @@ describe('hook interfaces', () => { * Should NOT prompt even though config.get(optOut) returned undefined * --no-telemetry flag wins */ - test('init --no-telemetry no prompt', async () => { + test('init --no-telemetry no notice', async () => { const hook = require('../src/hooks/init') expect(typeof hook).toBe('function') - inquirer.prompt = jest.fn() config.get = jest.fn().mockReturnValue(undefined) await hook({ config: { name: 'name', version: '0.0.1' }, argv: ['--no-telemetry'] }) - expect(inquirer.prompt).not.toHaveBeenCalled() + expect(noticeSpy).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() }) diff --git a/test/index.test.js b/test/index.test.js index b0306e9..7eafdae 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -15,7 +15,6 @@ const TheCommand = require('../src/commands/telemetry') const { stdout } = require('stdout-stderr') const config = require('@adobe/aio-lib-core-config') -jest.mock('inquirer') jest.mock('@adobe/aio-lib-core-config') const fetch = createFetch() diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index cd77de1..31ecd86 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -35,6 +35,19 @@ describe('telemetry-lib', () => { expect(telemetryLib.getOnMessage).toBeDefined() expect(telemetryLib.getOnMessage).toBeInstanceOf(Function) + + expect(telemetryLib.getNoticeMessage).toBeInstanceOf(Function) + expect(telemetryLib.notice).toBeInstanceOf(Function) + }) + + test('getNoticeMessage uses the default privacy link, or a provided one', async () => { + const withDefault = telemetryLib.getNoticeMessage('Adobe Developer CLI', 'aio') + expect(withDefault).toMatch('on by default') + expect(withDefault).toMatch('aio telemetry off') + expect(withDefault).toMatch('developer.adobe.com/app-builder/docs/guides/telemetry') + + const withLink = telemetryLib.getNoticeMessage('Adobe Developer CLI', 'aio', 'https://example.com/privacy') + expect(withLink).toMatch('https://example.com/privacy') }) test('exports init function', async () => { From aa5cee1df13564600bb608a5ea993fd058608c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Mon, 8 Jun 2026 10:33:56 -0700 Subject: [PATCH 26/31] feat(telemetry): point default telemetry URL at production proxy (ACNA-4597) Repoint DEFAULT_TELEMETRY_POST_URL from the stage namespace (53444-aioclitelemetryproxy-stage) to production (53444-aioclitelemetryproxy), now that the prod proxy is deployed and verified. Override via aioTelemetry.postUrl or AIO_TELEMETRY_POST_URL is unchanged. --- src/telemetry-lib.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index e4ca2c8..1096c84 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -17,7 +17,7 @@ const config = require('@adobe/aio-lib-core-config') const debug = require('debug')('aio-telemetry:telemetry-lib') /** Adobe I/O App Builder web action that forwards CLI metrics to New Relic (ingest key stays server-side). */ -const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy-stage.adobeio-static.net/api/v1/web/dx-excshell-1/telemetry' +const DEFAULT_TELEMETRY_POST_URL = 'https://53444-aioclitelemetryproxy.adobeio-static.net/api/v1/web/dx-excshell-1/telemetry' /** @returns {boolean} Whether `AIO_TELEMETRY_DISABLED` opts out (only the literal string `"true"`). */ function isEnvTelemetryDisabled () { From abaacfc3004b62f03bbca951b15d32c463bc97de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Mon, 8 Jun 2026 13:59:51 -0700 Subject: [PATCH 27/31] chore(telemetry): interpolate trackEvent debug line so values show under aio --- src/telemetry-lib.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 1096c84..7b34179 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -191,7 +191,7 @@ async function trackEvent (eventType, rawEventData = {}) { const optedOut = isDisabledForCommand || isEnvTelemetryDisabled() || config.get(`${configKey}.optOut`, 'global') === true const willSend = !optedOut - debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend) + debug(`trackEvent ${eventType} eventData=${JSON.stringify(eventData)} postUrl=${postUrl} willSend=${willSend}`) if (optedOut) { pendingCommandMetrics.length = 0 From e1f0b38d5b6835f6baa8112240b90a122e202e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Mon, 8 Jun 2026 14:59:19 -0700 Subject: [PATCH 28/31] refactor(telemetry): simplify one-time notice per review Drop the explicit 'To opt out, run ...' line from the telemetry notice (per @purplecabbage on PR #40) so it does not over-nudge opt-out; the privacy link already covers how to opt out. Removes the now-unused binName argument from getNoticeMessage/notice. --- src/hooks/init.js | 2 +- src/telemetry-lib.js | 12 +++++------- test/telemetry-lib.test.js | 5 ++--- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/hooks/init.js b/src/hooks/init.js index c3e68b3..1631fc9 100644 --- a/src/hooks/init.js +++ b/src/hooks/init.js @@ -46,7 +46,7 @@ module.exports = async function (opts) { // skip in CI (handled above) and when oclif-dev readme runs (it fires this event); // also skip for telemetry commands, where the user is already setting state. if (['readme', 'telemetry'].indexOf(opts.id) < 0) { - telemetryLib.notice(productName, binName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink) + telemetryLib.notice(productName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink) } } } diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 7b34179..12c113f 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -119,11 +119,9 @@ const getOnMessage = (productName, binName) => { const getOffMessage = (binName) => { return `\nTelemetry is off.\nIf you would like to turn telemetry on, simply run \`${binName} telemetry on\`` } -const getNoticeMessage = (productName, binName, privacyPolicyLink) => { - return `${productName} collects anonymous usage data to help us improve our products. ` + - 'Telemetry is on by default; read what we collect and how it is used here:\n' + - ` ${privacyPolicyLink || defaultPrivacyPolicyLink}\n` + - `To opt out, run \`${binName} telemetry off\` (or set AIO_TELEMETRY_DISABLED=true).` +const getNoticeMessage = (productName, privacyPolicyLink) => { + return `${productName} collects anonymous usage data to help us improve our products.\n` + + `Telemetry is on by default; read what we collect and how it is used here: ${privacyPolicyLink || defaultPrivacyPolicyLink}` } /** @@ -302,8 +300,8 @@ module.exports = { getOnMessage, getOffMessage, getNoticeMessage, - notice: (productName, binName, privacyPolicyLink) => { - console.log(getNoticeMessage(productName, binName, privacyPolicyLink)) + notice: (productName, privacyPolicyLink) => { + console.log(getNoticeMessage(productName, privacyPolicyLink)) config.set(`${configKey}.optOut`, false) trackEvent('telemetry-notice', 'shown') } diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 31ecd86..f4b2485 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -41,12 +41,11 @@ describe('telemetry-lib', () => { }) test('getNoticeMessage uses the default privacy link, or a provided one', async () => { - const withDefault = telemetryLib.getNoticeMessage('Adobe Developer CLI', 'aio') + const withDefault = telemetryLib.getNoticeMessage('Adobe Developer CLI') expect(withDefault).toMatch('on by default') - expect(withDefault).toMatch('aio telemetry off') expect(withDefault).toMatch('developer.adobe.com/app-builder/docs/guides/telemetry') - const withLink = telemetryLib.getNoticeMessage('Adobe Developer CLI', 'aio', 'https://example.com/privacy') + const withLink = telemetryLib.getNoticeMessage('Adobe Developer CLI', 'https://example.com/privacy') expect(withLink).toMatch('https://example.com/privacy') }) From 1df8142ac6fb9bfba9fe035838cbc41d0a07b488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Tue, 9 Jun 2026 12:24:13 -0700 Subject: [PATCH 29/31] fix(telemetry): flush on terminal events so error telemetry is delivered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched-flush design only spawned the flush worker on the postrun hook. But postrun does not run after a command fails or is not found, so those metrics were buffered in memory and silently dropped on exit — error/not-found telemetry was never delivered. Lifecycle (verified against @oclif/core 4.11.4 and @adobe/aio-cli 11.1.0): oclif runs postrun only after command.run() returns; on a throw the error propagates first, and on a not-found oclif throws before postrun. command_error is not an oclif hook — the host CLI fires it from its top-level catch. So no postrun follows an error or not-found. Flush on any terminal event (postrun, command-error, command-not-found), merging buffered non-terminal metrics into the batch. Update the hook tests to exercise the real lifecycle (the error/not-found hook flushes on its own, no manufactured postrun) and the README "Flush architecture" section. --- README.md | 4 ++-- src/telemetry-lib.js | 19 ++++++++++++++----- test/hooks.test.js | 15 +++++++-------- test/telemetry-lib.test.js | 12 ++++++++++++ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index cd86ede..2dc525a 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ AIO_TELEMETRY_DISABLED=yes aio app deploy Telemetry is **best-effort**: events are not persisted when the proxy is down or the network fails. -On **`postrun`**, any in-memory metrics from earlier hooks in the same command are merged with the `postrun` metric and the combined batch is handed off to a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI spawns the worker and immediately `unref()`s it, so the CLI can exit without waiting for the HTTP POST. If the POST fails (network error or non-2xx response), the batch is dropped; telemetry must not block or slow normal CLI use. +A flush happens on a **terminal event** — `postrun` (command succeeded), `command-error` (command threw), or `command-not-found`. oclif runs `postrun` only on success, so `command-error`/`command-not-found` must flush on their own; otherwise error telemetry (the most valuable signal) would be buffered and silently dropped on exit. On a terminal event, any in-memory metrics from earlier hooks in the same command are merged with the terminal metric and the combined batch is handed off to a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI spawns the worker and immediately `unref()`s it, so the CLI can exit without waiting for the HTTP POST. If the POST fails (network error or non-2xx response), the batch is dropped; telemetry must not block or slow normal CLI use. -Non-`postrun` events (for example `command-error`, `telemetry-notice`) are held in an **in-memory buffer** until that flush. If the process exits before `postrun` (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session). +Non-terminal events (for example `telemetry-notice`) are held in an **in-memory buffer** until the next terminal event flushes them. If the process exits before any terminal event (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session). ## Agent detection diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 12c113f..28bcb27 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -26,9 +26,12 @@ function isEnvTelemetryDisabled () { let isDisabledForCommand = false -/** Metrics for non-`postrun` events in the current command; merged into one POST on `postrun`. */ +/** Metrics for non-terminal events in the current command; merged into the POST on a terminal event. */ const pendingCommandMetrics = [] +/** Events after which oclif does not run `postrun`; each triggers an immediate flush. */ +const TERMINAL_EVENTS = ['postrun', 'command-error', 'command-not-found'] + /** * Detects GitHub Copilot Chat on PATH via the extension id in globalStorage paths (any OS path separator). * @@ -173,9 +176,10 @@ function formatEventDataAttribute (eventData) { } /** - * Records a telemetry event. Non-`postrun` metrics are held in memory and sent in a single - * batched POST when `postrun` runs. When enabled, the flush worker is detached so the CLI - * never waits on the network; failed deliveries are dropped (no disk queue). + * Records a telemetry event. Non-terminal metrics are held in memory and sent in a single + * batched POST on the next terminal event (`postrun`, `command-error`, `command-not-found`). + * When enabled, the flush worker is detached so the CLI never waits on the network; failed + * deliveries are dropped (no disk queue). * * @param {string} eventType prerun, postrun, command-error, command-not-found, telemetry * @param {object|string|number|undefined} [rawEventData] Optional hook payload (e.g. `{ message }` on errors). @@ -219,7 +223,12 @@ async function trackEvent (eventType, rawEventData = {}) { } } - if (eventType !== 'postrun') { + // `postrun` fires after a command completes successfully. `command-error` and + // `command-not-found` are *terminal*: oclif does NOT run `postrun` after them, so if we + // only flushed on `postrun` these (the most valuable signal) would be buffered and silently + // dropped on exit. Flush on any terminal event so error telemetry is actually delivered. + const isTerminalEvent = TERMINAL_EVENTS.includes(eventType) + if (!isTerminalEvent) { pendingCommandMetrics.push(metric) return } diff --git a/test/hooks.test.js b/test/hooks.test.js index aa24115..fb878fa 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -41,32 +41,31 @@ describe('hook interfaces', () => { noticeSpy.mockRestore() }) - test('command-error', async () => { + // oclif does not run `postrun` after a command throws, so the error hook itself must flush. + test('command-error flushes immediately (no postrun follows an error)', async () => { config.get.mockImplementation((key) => (String(key).includes('optOut') ? false : 'clientid')) telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/command-error') expect(typeof hook).toBe('function') await hook({ message: 'msg' }) - expect(spawn).not.toHaveBeenCalled() - await telemetryLib.trackEvent('postrun') expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) const body = JSON.parse(flushPayload.body) - expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error', 'postrun']) + expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + expect(body[0].metrics[0].attributes.commandSuccess).toBe(false) }) - test('command-not-found', async () => { + // command-not-found is terminal too: no command runs, so no postrun. + test('command-not-found flushes immediately', async () => { config.get.mockImplementation((key) => (String(key).includes('optOut') ? false : 'clientid')) telemetryLib.init('name@0.0.1', 'aio', mockPackageJson.aioTelemetry) const hook = require('../src/hooks/command-not-found') expect(typeof hook).toBe('function') await hook({ id: 'id' }) - expect(spawn).not.toHaveBeenCalled() - await telemetryLib.trackEvent('postrun') expect(spawn).toHaveBeenCalledTimes(1) const flushPayloadNf = JSON.parse(spawn.mock.calls[0][1][1]) const bodyNf = JSON.parse(flushPayloadNf.body) - expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found', 'postrun']) + expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) }) test('init shows one-time notice on first run', async () => { diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index f4b2485..1f7721a 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -82,6 +82,18 @@ describe('telemetry-lib', () => { expect(body[0].metrics[1].attributes.eventType).toBe('postrun') }) + test('terminal command-error flushes buffered events without a postrun', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binErr', {}) + await telemetryLib.trackEvent('telemetry-custom-event') + expect(spawn).not.toHaveBeenCalled() + await telemetryLib.trackEvent('command-error', { message: 'boom' }) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-custom-event', 'command-error']) + }) + test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => { global.prerunTimer = Date.now() - 40 config.get.mockReturnValue('clientidxyz') From a7ca31faefc9970d0149f0f671c0351d9fcc5a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Tue, 9 Jun 2026 12:24:41 -0700 Subject: [PATCH 30/31] fix(telemetry): de-dupe the typo path without masking nested-command telemetry A mistyped command fires command-not-found (from oclif) and then command-error (the host CLI rethrowing the same typo, exit 127), recording a typo twice. Track a per-process commandNotFoundFired flag (reset in init) and drop only the single command-error that follows a command-not-found, then clear the flag so a later, unrelated command-error still flushes. Scoped to the typo on purpose: init fires once per process while nested config.runCommand calls re-fire prerun/postrun (e.g. app:undeploy -> app:clean), so a blunt "first terminal event wins" flag would mask real command-errors and misattribute commands. Also clear the flag in trackPrerun so plugin-not-found's "Did you mean ...? Yes" (which runs the suggestion) doesn't mask its errors. Mark command-not-found as commandSuccess: false (a missing command did not succeed). Add tests for the single-typo entry, flag reset, both "Did you mean?" answers, and nested commands. --- src/telemetry-lib.js | 30 ++++++++++++++------ test/hooks.test.js | 1 + test/telemetry-lib.test.js | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index 28bcb27..a59520f 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -29,9 +29,15 @@ let isDisabledForCommand = false /** Metrics for non-terminal events in the current command; merged into the POST on a terminal event. */ const pendingCommandMetrics = [] -/** Events after which oclif does not run `postrun`; each triggers an immediate flush. */ +/** Events with no `postrun` after them; each flushes immediately so error telemetry isn't lost. */ const TERMINAL_EVENTS = ['postrun', 'command-error', 'command-not-found'] +/** Events that mean the command did not succeed (so `commandSuccess` is false). */ +const FAILED_COMMAND_EVENTS = ['command-error', 'command-not-found'] + +/** Set by `command-not-found` so we can drop the host's immediate `command-error` rethrow of the same typo. */ +let commandNotFoundFired = false + /** * Detects GitHub Copilot Chat on PATH via the extension id in globalStorage paths (any OS path separator). * @@ -215,7 +221,7 @@ async function trackEvent (eventType, rawEventData = {}) { command: prerunEvent.command, commandDuration: timestamp - prerunEvent.start, commandFlags: prerunEvent.flags.toString(), - commandSuccess: eventType !== 'command-error', + commandSuccess: !FAILED_COMMAND_EVENTS.includes(eventType), nodeVersion: process.version, osNameVersion, invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human', @@ -223,16 +229,21 @@ async function trackEvent (eventType, rawEventData = {}) { } } - // `postrun` fires after a command completes successfully. `command-error` and - // `command-not-found` are *terminal*: oclif does NOT run `postrun` after them, so if we - // only flushed on `postrun` these (the most valuable signal) would be buffered and silently - // dropped on exit. Flush on any terminal event so error telemetry is actually delivered. - const isTerminalEvent = TERMINAL_EVENTS.includes(eventType) - if (!isTerminalEvent) { + // Non-terminal events buffer; terminal events flush (oclif runs no postrun after error/not-found). + if (!TERMINAL_EVENTS.includes(eventType)) { pendingCommandMetrics.push(metric) return } + // A typo fires command-not-found, then the host rethrows it as command-error; drop that rethrow. + if (eventType === 'command-not-found') { + commandNotFoundFired = true + } else if (eventType === 'command-error' && commandNotFoundFired) { + commandNotFoundFired = false + debug('dropping command-error that rethrows a command-not-found (same typo)') + return + } + const mergedMetrics = [...pendingCommandMetrics, metric] pendingCommandMetrics.length = 0 const mergedBody = JSON.stringify([{ metrics: mergedMetrics }]) @@ -262,12 +273,15 @@ async function trackEvent (eventType, rawEventData = {}) { */ function trackPrerun (command, flags, start) { prerunEvent = { command, flags, start } + // A real command is now running (e.g. an accepted "did you mean" suggestion), so its errors count. + commandNotFoundFired = false } module.exports = { getInvocationContext, init: (versionString, binName, remoteConf = {}) => { pendingCommandMetrics.length = 0 + commandNotFoundFired = false global.commandHookStartTime = Date.now() rootCliVersion = versionString postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL diff --git a/test/hooks.test.js b/test/hooks.test.js index fb878fa..74e6c59 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -66,6 +66,7 @@ describe('hook interfaces', () => { const flushPayloadNf = JSON.parse(spawn.mock.calls[0][1][1]) const bodyNf = JSON.parse(flushPayloadNf.body) expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) + expect(bodyNf[0].metrics[0].attributes.commandSuccess).toBe(false) }) test('init shows one-time notice on first run', async () => { diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index 1f7721a..a2664dd 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -94,6 +94,63 @@ describe('telemetry-lib', () => { expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-custom-event', 'command-error']) }) + test('a typo (command-not-found then host command-error) is recorded as a single command-not-found', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binOnce', {}) + // a typo fires command-not-found (from oclif) and then command-error (rethrown by the host) + await telemetryLib.trackEvent('command-not-found', 'bogus') + await telemetryLib.trackEvent('command-error', { message: 'Run aio help' }) + expect(spawn).toHaveBeenCalledTimes(1) + const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) + const body = JSON.parse(flushPayload.body) + expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) + }) + + test('only the first command-error after a not-found is suppressed; a later one still flushes', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binReset', {}) + await telemetryLib.trackEvent('command-not-found', 'bogus') // flush #1 + await telemetryLib.trackEvent('command-error', { message: 'Run aio help' }) // dropped: the typo rethrow + await telemetryLib.trackEvent('command-error', { message: 'unrelated later error' }) // flush #2 + expect(spawn).toHaveBeenCalledTimes(2) + expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + }) + + test('"Did you mean ...? Yes": not-found then the suggested command runs and its postrun flushes', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binYes', {}) + await telemetryLib.trackEvent('command-not-found', 'telemetryd') // flush #1 + telemetryLib.trackPrerun('telemetry', [], Date.now()) // suggestion accepted -> command runs + await telemetryLib.trackEvent('postrun') // flush #2 + expect(spawn).toHaveBeenCalledTimes(2) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['postrun']) + }) + + test('"Did you mean ...? Yes" then the suggested command errors: that command-error is NOT masked', async () => { + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binYesErr', {}) + await telemetryLib.trackEvent('command-not-found', 'telemetryd') // flush #1 + telemetryLib.trackPrerun('telemetry', [], Date.now()) // suggestion accepted -> command runs (clears the flag) + await telemetryLib.trackEvent('command-error', { message: 'the real command failed' }) // flush #2 + expect(spawn).toHaveBeenCalledTimes(2) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + }) + + test('nested commands in one process each flush (no masking): two postruns => two flushes', async () => { + // A command that calls config.runCommand re-fires prerun/postrun for the child; init runs once. + // The child postrun must NOT suppress the parent terminal event. + config.get.mockReturnValue('clientidxyz') + telemetryLib.init('a@4', 'binNested', {}) + telemetryLib.trackPrerun('app:clean', [], Date.now()) + await telemetryLib.trackEvent('postrun') // child (e.g. app:clean) succeeds + telemetryLib.trackPrerun('app:undeploy', [], Date.now()) + await telemetryLib.trackEvent('command-error', { message: 'parent failed after child succeeded' }) + expect(spawn).toHaveBeenCalledTimes(2) + expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['postrun']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + }) + test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => { global.prerunTimer = Date.now() - 40 config.get.mockReturnValue('clientidxyz') From 4b6cc7a7275367793d96063499bb8d9786c240af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20Garc=C3=ADa?= Date: Wed, 10 Jun 2026 10:05:28 -0700 Subject: [PATCH 31/31] fix(telemetry): emit event type as non-reserved attribute `eventName` `eventType` is a New Relic reserved word and is dropped on Metric API ingest, so the attribute was unqueryable in NRQL (every value came back null). Map it to `eventName` at the serialization boundary; the internal `eventType` variable is unchanged. Verified end-to-end: the attribute now lands and is queryable. --- README.md | 2 +- src/telemetry-lib.js | 4 +++- test/flush-worker.test.js | 2 +- test/hooks.test.js | 10 +++++----- test/telemetry-lib.test.js | 20 ++++++++++---------- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2dc525a..886a307 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Example shape of the metric payload: "value": 1, "timestamp": 1673404918437, "attributes": { - "eventType": "postrun", + "eventName": "postrun", "eventData": "{}", "cliVersion": "@adobe/aio-cli@11.0.2", "clientId": 264421030538, diff --git a/src/telemetry-lib.js b/src/telemetry-lib.js index a59520f..662490d 100644 --- a/src/telemetry-lib.js +++ b/src/telemetry-lib.js @@ -214,7 +214,9 @@ async function trackEvent (eventType, rawEventData = {}) { value: 1, timestamp, attributes: { - eventType, + // NB: the wire attribute is `eventName`, not `eventType` — `eventType` is a New Relic + // reserved word and is dropped on Metric API ingest, so it is unqueryable in NRQL. + eventName: eventType, eventData: formatEventDataAttribute(eventData), cliVersion: rootCliVersion, clientId, diff --git a/test/flush-worker.test.js b/test/flush-worker.test.js index f47a15c..5dd9c18 100644 --- a/test/flush-worker.test.js +++ b/test/flush-worker.test.js @@ -16,7 +16,7 @@ const fetch = createFetch() const { main } = require('../src/flush-worker') const PROXY = 'https://telemetry-proxy.example/api/v1/web/dx-excshell-1/telemetry' -const METRIC = { name: 'aio.cli.telemetry', type: 'gauge', value: 1, timestamp: 1000, attributes: { eventType: 'postrun' } } +const METRIC = { name: 'aio.cli.telemetry', type: 'gauge', value: 1, timestamp: 1000, attributes: { eventName: 'postrun' } } const BODY = JSON.stringify([{ metrics: [METRIC] }]) const flushArg = (body = BODY, extraHeaders) => { const base = { body, postUrl: PROXY } diff --git a/test/hooks.test.js b/test/hooks.test.js index 74e6c59..2009a1b 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -51,7 +51,7 @@ describe('hook interfaces', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) const body = JSON.parse(flushPayload.body) - expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + expect(body[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-error']) expect(body[0].metrics[0].attributes.commandSuccess).toBe(false) }) @@ -65,7 +65,7 @@ describe('hook interfaces', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayloadNf = JSON.parse(spawn.mock.calls[0][1][1]) const bodyNf = JSON.parse(flushPayloadNf.body) - expect(bodyNf[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) + expect(bodyNf[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-not-found']) expect(bodyNf[0].metrics[0].attributes.commandSuccess).toBe(false) }) @@ -83,7 +83,7 @@ describe('hook interfaces', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayloadAcc = JSON.parse(spawn.mock.calls[0][1][1]) const bodyAcc = JSON.parse(flushPayloadAcc.body) - expect(bodyAcc[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-notice', 'postrun']) + expect(bodyAcc[0].metrics.map((m) => m.attributes.eventName)).toEqual(['telemetry-notice', 'postrun']) expect(bodyAcc[0].metrics[0].attributes.eventData).toBe('shown') process.env = preEnv }) @@ -158,7 +158,7 @@ describe('hook interfaces', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayloadCe = JSON.parse(spawn.mock.calls[0][1][1]) const bodyCe = JSON.parse(flushPayloadCe.body) - expect(bodyCe[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-custom-event', 'postrun']) + expect(bodyCe[0].metrics.map((m) => m.attributes.eventName)).toEqual(['telemetry-custom-event', 'postrun']) }) test('postrun', async () => { @@ -170,7 +170,7 @@ describe('hook interfaces', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) expect(flushPayload.headers).toBeUndefined() - expect(flushPayload.body).toContain('"eventType":"postrun"') + expect(flushPayload.body).toContain('"eventName":"postrun"') }) /** diff --git a/test/telemetry-lib.test.js b/test/telemetry-lib.test.js index a2664dd..df40047 100644 --- a/test/telemetry-lib.test.js +++ b/test/telemetry-lib.test.js @@ -78,8 +78,8 @@ describe('telemetry-lib', () => { const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) const body = JSON.parse(flushPayload.body) expect(body[0].metrics).toHaveLength(2) - expect(body[0].metrics[0].attributes.eventType).toBe('telemetry-custom-event') - expect(body[0].metrics[1].attributes.eventType).toBe('postrun') + expect(body[0].metrics[0].attributes.eventName).toBe('telemetry-custom-event') + expect(body[0].metrics[1].attributes.eventName).toBe('postrun') }) test('terminal command-error flushes buffered events without a postrun', async () => { @@ -91,7 +91,7 @@ describe('telemetry-lib', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) const body = JSON.parse(flushPayload.body) - expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['telemetry-custom-event', 'command-error']) + expect(body[0].metrics.map((m) => m.attributes.eventName)).toEqual(['telemetry-custom-event', 'command-error']) }) test('a typo (command-not-found then host command-error) is recorded as a single command-not-found', async () => { @@ -103,7 +103,7 @@ describe('telemetry-lib', () => { expect(spawn).toHaveBeenCalledTimes(1) const flushPayload = JSON.parse(spawn.mock.calls[0][1][1]) const body = JSON.parse(flushPayload.body) - expect(body[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) + expect(body[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-not-found']) }) test('only the first command-error after a not-found is suppressed; a later one still flushes', async () => { @@ -113,8 +113,8 @@ describe('telemetry-lib', () => { await telemetryLib.trackEvent('command-error', { message: 'Run aio help' }) // dropped: the typo rethrow await telemetryLib.trackEvent('command-error', { message: 'unrelated later error' }) // flush #2 expect(spawn).toHaveBeenCalledTimes(2) - expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-not-found']) - expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-not-found']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-error']) }) test('"Did you mean ...? Yes": not-found then the suggested command runs and its postrun flushes', async () => { @@ -124,7 +124,7 @@ describe('telemetry-lib', () => { telemetryLib.trackPrerun('telemetry', [], Date.now()) // suggestion accepted -> command runs await telemetryLib.trackEvent('postrun') // flush #2 expect(spawn).toHaveBeenCalledTimes(2) - expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['postrun']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['postrun']) }) test('"Did you mean ...? Yes" then the suggested command errors: that command-error is NOT masked', async () => { @@ -134,7 +134,7 @@ describe('telemetry-lib', () => { telemetryLib.trackPrerun('telemetry', [], Date.now()) // suggestion accepted -> command runs (clears the flag) await telemetryLib.trackEvent('command-error', { message: 'the real command failed' }) // flush #2 expect(spawn).toHaveBeenCalledTimes(2) - expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-error']) }) test('nested commands in one process each flush (no masking): two postruns => two flushes', async () => { @@ -147,8 +147,8 @@ describe('telemetry-lib', () => { telemetryLib.trackPrerun('app:undeploy', [], Date.now()) await telemetryLib.trackEvent('command-error', { message: 'parent failed after child succeeded' }) expect(spawn).toHaveBeenCalledTimes(2) - expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['postrun']) - expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventType)).toEqual(['command-error']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[0][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['postrun']) + expect(JSON.parse(JSON.parse(spawn.mock.calls[1][1][1]).body)[0].metrics.map((m) => m.attributes.eventName)).toEqual(['command-error']) }) test('postrun adds durationMs to eventData when the hook omits payload and prerunTimer is set', async () => {