Skip to content

Telemetry proxy - agent detection#39

Open
purplecabbage wants to merge 30 commits into
masterfrom
telemetry-proxy
Open

Telemetry proxy - agent detection#39
purplecabbage wants to merge 30 commits into
masterfrom
telemetry-proxy

Conversation

@purplecabbage

@purplecabbage purplecabbage commented May 9, 2026

Copy link
Copy Markdown
Member

Description

This PR updates the telemetry plugin to (1) send metrics via a proxy using a detached “fire-and-forget” flush worker and a persistent on-disk retry queue, and (2) add AI agent vs human invocation detection and include that context in every event.

Changes:

Replace direct HTTP posting from the main process with a detached subprocess flush worker (src/flush-worker.js) and add a persistent retry queue (src/queue-store.js).
Add agent detection via environment variables (plus Copilot PATH markers) and attach invocation_context / agent_name attributes to emitted telemetry.
Update tests and README to reflect the new flush architecture and additional telemetry attributes; remove unused runtime deps.

Related Issue

ACNA-4403ACNA-4403

Motivation and Context

We want to know who, or what is using the cli and how it is being used.

How Has This Been Tested?

Screenshots (if appropriate):

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • I have signed the Adobe Open Source CLA.
  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

- 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.
@codecov

codecov Bot commented May 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the telemetry plugin to (1) send metrics via a proxy using a detached “fire-and-forget” flush worker and a persistent on-disk retry queue, and (2) add AI agent vs human invocation detection and include that context in every event.

Changes:

  • Replace direct HTTP posting from the main process with a detached subprocess flush worker (src/flush-worker.js) and add a persistent retry queue (src/queue-store.js).
  • Add agent detection via environment variables (plus Copilot PATH markers) and attach invocation_context / agent_name attributes to emitted telemetry.
  • Update tests and README to reflect the new flush architecture and additional telemetry attributes; remove unused runtime deps.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/telemetry-lib.js Builds New Relic-style metric payloads, adds agent detection, and spawns detached flush worker instead of POSTing directly.
src/flush-worker.js New detached worker that POSTs merged queued+current metrics and persists failures for retry.
src/queue-store.js New persistent JSON-backed queue store under the aio config directory (XDG-aware).
test/telemetry-lib.test.js Updates tests to assert spawned worker payloads, agent context attributes, env overrides, and postrun duration behavior.
test/hooks.test.js Updates hook interface tests to validate flush-worker spawning instead of direct fetch usage.
test/queue-store.test.js Adds unit tests for queue path resolution and queue read/write/clear behavior.
test/flush-worker.test.js Adds unit tests for flush-worker merging, posting, and queue persistence on failure.
README.md Documents environment opt-out, flush architecture, agent detection, and new payload shape.
package.json Removes unused dependencies (os-name, splunk-logging).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/telemetry-lib.js Outdated
Comment thread src/telemetry-lib.js Outdated
Comment thread src/telemetry-lib.js Outdated
Comment thread src/flush-worker.js Outdated
Comment thread src/flush-worker.js Outdated
Comment on lines +49 to +71
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.
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]

Comment thread src/flush-worker.js
Comment on lines +72 to +84
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ batches: [{ metrics: allMetrics }] })
})
// Successful delivery — the queue is no longer needed.
clearQueue()
} catch {
// Network failure — persist all metrics so the next invocation can retry.
writeQueue(allMetrics)
}
Comment thread src/flush-worker.js Outdated
Comment on lines +67 to +84
// 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)
await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ batches: [{ metrics: allMetrics }] })
})
// Successful delivery — the queue is no longer needed.
clearQueue()
} catch {
// Network failure — persist all metrics so the next invocation can retry.
writeQueue(allMetrics)
}
Comment thread README.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.

Comment thread src/flush-worker.js Outdated
Comment thread src/flush-worker.js Outdated
Comment thread src/telemetry-lib.js Outdated
Comment thread src/telemetry-lib.js Outdated
Comment on lines 215 to 221
init: (versionString, binName, remoteConf = {}) => {
global.commandHookStartTime = Date.now()
rootCliVersion = versionString
if (remoteConf.fetchHeaders) {
fetchHeaders = remoteConf.fetchHeaders
}
if (remoteConf.postUrl) {
postUrl = remoteConf.postUrl
postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL
fetchHeaders = {
'Content-Type': 'application/json'
}
Comment thread README.md
Comment thread src/telemetry-lib.js Outdated
purplecabbage and others added 2 commits May 8, 2026 18:54
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

This is a well-structured refactor that replaces a synchronous Splunk/AEP telemetry POST with a fire-and-forget detached subprocess pattern, adds persistent retry queuing, and introduces agent detection. The code is clear, well-tested, and follows good practices. A few minor issues worth addressing: the header filtering in flush-worker.js has a case-sensitivity bug that could allow Api-Key through, the queue can grow unboundedly on repeated failures, and the flushPayload passes fetchHeaders to the worker which then redundantly re-merges DEFAULT_HEADERS.

📝 4 suggestion(s) - Please review inline comments below.


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
Comment on lines +54 to +57
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The filter strips headers whose key lowercased equals 'api-key', but the DEFAULT_HEADERS are merged after, meaning any header named exactly 'Content-Type' passed in fetchHeaders will correctly override. However the intent seems to be blocking sensitive credential headers. The filter only blocks 'api-key' but not common credential headers like 'authorization', 'x-api-key', 'x-ingest-key', etc. At minimum add 'authorization' and 'x-api-key' to the blocklist, or invert to an allowlist.

Suggested change
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Comment thread src/queue-store.js Outdated
Comment on lines +57 to +65
* @param {Array<object>} 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is no cap on the queue size. If the telemetry endpoint is down for an extended period, the queue will grow indefinitely, consuming disk space. Add a max-items guard before writing.

Suggested change
* @param {Array<object>} 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
function writeQueue (items) {
const MAX_QUEUE_SIZE = 100
const file = getQueuePath()
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
const capped = items.slice(-MAX_QUEUE_SIZE)
fs.writeFileSync(file, JSON.stringify(capped), 'utf8')
} catch {
// silently ignore — failing to persist the queue must not affect the CLI
}
}

Comment thread src/telemetry-lib.js Outdated
Comment on lines +182 to +186
nodeVersion: process.version,
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The flushPayload includes fetchHeaders, but flush-worker.js also defines its own DEFAULT_HEADERS and merges them again. This means Content-Type is always set twice (harmlessly), but the round-trip serialization of headers is redundant if the worker already applies defaults. More importantly, if fetchHeaders is mutated between spawning and the worker parsing (unlikely but possible in tests), behavior could be surprising. Consider only passing extra/override headers and letting the worker own DEFAULT_HEADERS entirely.

Suggested change
nodeVersion: process.version,
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
const flushPayload = JSON.stringify({
body: fetchConfig.body,
postUrl,
headers: Object.fromEntries(
Object.entries(fetchHeaders).filter(([k]) => k.toLowerCase() !== 'content-type')
)
})

Comment thread src/telemetry-lib.js Outdated
Comment on lines 147 to 149
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The AIO_TELEMETRY_DISABLED check uses a truthy JS check on the env string, meaning AIO_TELEMETRY_DISABLED=0 or AIO_TELEMETRY_DISABLED=false would still disable telemetry, which contradicts the README's claim of 'any truthy value'. This is inconsistent — env vars are always strings, so '0' and 'false' are truthy in JS. Either document this clearly or normalize the check.

Suggested change
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
const isEnvDisabled = process.env.AIO_TELEMETRY_DISABLED !== undefined &&
process.env.AIO_TELEMETRY_DISABLED !== '' &&
process.env.AIO_TELEMETRY_DISABLED !== '0' &&
process.env.AIO_TELEMETRY_DISABLED.toLowerCase() !== 'false'
const optedOut = isDisabledForCommand || isEnvDisabled || config.get(`${configKey}.optOut`, 'global') === true

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

This is a substantial, well-structured refactor: replacing a direct HTTP call with a fire-and-forget detached subprocess, adding a persistent retry queue, and introducing agent-detection telemetry. Code quality is good overall, tests are comprehensive, and the architecture is sound. Three previously raised issues remain unaddressed (header blocklist, queue size cap, and the AIO_TELEMETRY_DISABLED truthy-string semantics), and there is one new issue with an argument injection risk when passing the flush payload as a CLI argument.

📝 4 suggestion(s) (1 new, 3 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The filter strips only 'api-key'. Common credential headers like 'authorization', 'x-api-key', and 'x-ingest-key' are not blocked. Expand the blocklist.

Suggested change
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Comment thread src/queue-store.js Outdated
/**
* 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<object>} items Flat array of New Relic metric objects.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] There is no cap on the queue size. If the endpoint is unreachable for an extended period the file will grow unboundedly. Cap at a reasonable maximum.

Suggested change
* @param {Array<object>} items Flat array of New Relic metric objects.
function writeQueue (items) {
const MAX_QUEUE_SIZE = 100
const file = getQueuePath()
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
const capped = items.slice(-MAX_QUEUE_SIZE)
fs.writeFileSync(file, JSON.stringify(capped), 'utf8')
} catch {
// silently ignore — failing to persist the queue must not affect the CLI
}
}

Comment thread src/telemetry-lib.js Outdated
osNameVersion
}
})
body: JSON.stringify([{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] AIO_TELEMETRY_DISABLED uses a JS truthy check on a string. The strings '0' and 'false' are truthy in JS, so they would incorrectly disable telemetry. Normalize the check to match the documented behaviour.

Suggested change
body: JSON.stringify([{
const envDisabled = process.env.AIO_TELEMETRY_DISABLED !== undefined &&
process.env.AIO_TELEMETRY_DISABLED !== '' &&
process.env.AIO_TELEMETRY_DISABLED !== '0' &&
process.env.AIO_TELEMETRY_DISABLED.toLowerCase() !== 'false'
const optedOut = isDisabledForCommand || envDisabled || config.get(`${configKey}.optOut`, 'global') === true

Comment thread src/telemetry-lib.js Outdated
Comment on lines +183 to +191
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const flushPayload = JSON.stringify({
body: fetchConfig.body,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The entire JSON-serialized flush payload is passed as a single CLI argument (process.argv[2]). On Windows, argument length is capped around 32 767 characters and can silently truncate or fail. For large queues or long eventData strings this will cause data loss. Consider writing the payload to a temp file and passing the file path instead, then deleting it in the worker after parsing.

Suggested change
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const flushPayload = JSON.stringify({
body: fetchConfig.body,
const tmpFile = path.join(os.tmpdir(), `aio-telemetry-${Date.now()}-${Math.random().toString(36).slice(2)}.json`)
try {
require('fs').writeFileSync(tmpFile, flushPayload, 'utf8')
} catch (err) {
debug('Failed to write telemetry payload to temp file: %O', err)
return
}
try {
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), tmpFile], {
env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' },
detached: true,
stdio: 'ignore'
})
child.unref()
} catch (err) {
debug('Failed to launch telemetry flush worker: %O', err)
}

@github-actions github-actions Bot dismissed their stale review May 11, 2026 18:57

Superseded by new review

@purplecabbage purplecabbage requested a review from Copilot May 11, 2026 19:29
@github-actions github-actions Bot dismissed their stale review May 11, 2026 19:30

Superseded by new review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The refactor is well-structured and the test coverage is thorough. Three issues from the previous review remain unaddressed (header blocklist too narrow, unbounded queue, AIO_TELEMETRY_DISABLED truthy-string bug), and the Windows argument-length concern is still present. One new minor issue exists: fetchHeaders is re-initialized to a bare Content-Type object in init, so any remoteConf.fetchHeaders passed by the host CLI is silently ignored despite being documented in the README.

📝 5 suggestion(s) (1 new, 4 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

⚠️ Inline comments could not be attached (lines not in diff). See summary above.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Comment thread src/telemetry-lib.js Outdated
Comment on lines 227 to 233
init: (versionString, binName, remoteConf = {}) => {
global.commandHookStartTime = Date.now()
rootCliVersion = versionString
if (remoteConf.fetchHeaders) {
fetchHeaders = remoteConf.fetchHeaders
}
if (remoteConf.postUrl) {
postUrl = remoteConf.postUrl
postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL
fetchHeaders = {
'Content-Type': 'application/json'
}
Comment thread src/telemetry-lib.js
Comment on lines +39 to +46
// 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.
*/
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') },
Comment thread src/flush-worker.js Outdated
Comment on lines +54 to +70
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
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]

Comment thread src/queue-store.js Outdated
Comment on lines +71 to +82
/**
* Appends metric objects to the end of the queue (read + merge + write).
* Silently ignores invalid input or write errors.
* @param {Array<object>} 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])
}
Comment thread README.md
Comment on lines +44 to +48
- `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`

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

This is a well-structured refactor that replaces a direct Splunk/AEP HTTP POST with a fire-and-forget detached flush worker, adds a persistent retry queue, and introduces agent detection. The code is clean, well-documented, and has comprehensive test coverage. A few minor issues around security, correctness, and robustness are noted below.

📝 6 suggestion(s) - Please review inline comments below.


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
Comment on lines +50 to +55
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The header filter only blocks 'api-key' (case-insensitively), but callers could inadvertently pass 'authorization', 'x-api-key', or other sensitive headers. Since the parent already controls fetchHeaders and strips secrets before spawning, consider either allowlisting known safe headers or at minimum blocking a broader set of sensitive header names.

Suggested change
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Comment thread src/flush-worker.js
Comment on lines +57 to +59
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If body is valid JSON but does not contain an array at index 0, or if metrics is missing, this will throw a TypeError that is silently swallowed. Add a guard to avoid misleading silent failures.

Suggested change
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
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 BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
currentMetrics = parsedBody[0].metrics

Comment thread src/queue-store.js Outdated
Comment on lines +64 to +67
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The queue file can grow unboundedly if the telemetry endpoint is unreachable for a long time (e.g. offline laptop). Consider capping the number of retained metrics to prevent disk/memory issues on the next flush.

Suggested change
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
function readQueue () {
const MAX_QUEUE_SIZE = 500
try {
const data = fs.readFileSync(getQueuePath(), 'utf8')
const parsed = JSON.parse(data)
if (!Array.isArray(parsed)) return []
return parsed.slice(-MAX_QUEUE_SIZE)
} catch {
return []
}
}

Comment thread src/telemetry-lib.js Outdated
Comment on lines +155 to +157
debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend)

if (optedOut) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

process.env.AIO_TELEMETRY_DISABLED is truthy for any non-empty string (including '0' or 'false'). This is documented intentionally, but it may surprise operators. At minimum, add a comment clarifying this is intentional and consistent with standard CLI env-var conventions.

Suggested change
debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend)
if (optedOut) {
// AIO_TELEMETRY_DISABLED: any non-empty string disables telemetry (including '0'/'false').
// This is intentional — operators should set it to '1' to disable, and unset/empty to enable.
const optedOut = isDisabledForCommand || process.env.AIO_TELEMETRY_DISABLED || config.get(`${configKey}.optOut`, 'global') === true

Comment thread src/telemetry-lib.js Outdated
Comment on lines +187 to +198
}]
}])
}
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The flush payload passes fetchHeaders (which includes user-supplied headers) as a CLI argument string. If fetchHeaders contains values with characters that are significant in shell contexts (quotes, newlines), and the spawned process is ever launched via shell:true, this could be a command injection vector. The current spawn uses shell:false (default) so it is safe today, but it is worth adding an explicit comment and ensuring shell is never set to true.

Suggested change
}]
}])
}
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({
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
// shell MUST remain false (the default) — flushPayload is passed as a raw argument
// to avoid any shell interpolation of header values or URLs.
env: { ...process.env, AIO_TELEMETRY_DISABLED: '1' },
detached: true,
stdio: 'ignore'
})

Comment thread src/telemetry-lib.js

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

This is a substantial refactor that replaces a direct HTTP POST with a fire-and-forget detached subprocess queue/flush architecture. The code is well-structured with good test coverage. Several previously-raised security issues remain unaddressed (incomplete header denylist in both flush-worker.js and telemetry-lib.js, full process.env passed to child, fragile body re-parsing, non-string PATH guard, redundant mkdirSync in readQueue), plus one new structural issue in flush-worker.js where the POST body format is inconsistent with what telemetry-lib.js produces.

📝 8 suggestion(s) (1 new, 7 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The header filter only blocks 'api-key' (case-insensitively), but callers could inadvertently pass 'authorization', 'x-api-key', or other sensitive headers. Consider blocking a broader set of sensitive header names.

Suggested change
const safe = Object.fromEntries(
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js Outdated
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
currentMetrics = JSON.parse(body)[0].metrics

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] If body is valid JSON but does not contain an array at index 0, or if metrics is missing, this will throw a TypeError that is silently swallowed by the outer catch. Add an explicit guard.

Suggested change
currentMetrics = JSON.parse(body)[0].metrics
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
currentMetrics = parsedBody[0].metrics

Comment thread src/flush-worker.js
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The flush-worker POSTs with body shape { batches: [{ metrics: allMetrics }] } but telemetry-lib.js serializes the body as [{ metrics: [...] }] (a top-level array). These formats are inconsistent — the worker re-parses the body to extract [0].metrics correctly, but then re-wraps in a different shape. Both sides should agree on the wire format sent to the proxy.

Suggested change
method: 'POST',
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify([{ metrics: allMetrics }])
})

Comment thread src/telemetry-lib.js
Comment thread src/telemetry-lib.js Outdated
}

const flushPayload = JSON.stringify({
body: fetchConfig.body,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush worker subprocess inherits the full process.env (including potentially sensitive variables like AWS credentials, tokens, etc.) with only AIO_TELEMETRY_DISABLED added. Consider passing a minimal env to the worker instead of spreading all of process.env.

Suggested change
body: fetchConfig.body,
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
NODE_PATH: process.env.NODE_PATH,
AIO_TELEMETRY_DISABLED: '1'
},
detached: true,
stdio: 'ignore'
})

Comment thread src/telemetry-lib.js Outdated
commandDuration: timestamp - prerunEvent.start,
commandFlags: prerunEvent.flags.toString(),
commandSuccess: eventType !== 'command-error',
nodeVersion: process.version,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The line const metrics = JSON.parse(fetchConfig.body)[0].metrics re-parses JSON that was just serialized. The metrics array is already available locally. Build it first and reference it directly.

Suggested change
nodeVersion: process.version,
const metricsArray = [{
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 fetchConfig = {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify([{ metrics: metricsArray }])
}
const metrics = metricsArray

Comment thread src/telemetry-lib.js
}

// TODO: detect VSCODE run as an agent
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent checks pathValue.includes(...) without guarding against a non-string value. On systems where PATH is set to a non-string (e.g. via test injection), this would throw a TypeError. Add a type guard.

Suggested change
/**
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/queue-store.js Outdated
function writeQueue (items) {
const file = getQueuePath()
const toWrite = normalizeQueueItems(items)
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] readQueue writes back a truncated file when it finds an oversized queue, but the mkdirSync call is unnecessary here since the file already exists (readFileSync succeeded). Remove the redundant mkdirSync in the shrink-rewrite path.

Suggested change
try {
if (parsed.length > normalized.length) {
try {
fs.writeFileSync(file, JSON.stringify(normalized), 'utf8')
} catch {
// same as writeQueue — never throw from telemetry persistence
}
}

@github-actions github-actions Bot dismissed their stale review May 14, 2026 17:13

Superseded by new review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The diff introduces a major refactor: fire-and-forget flush worker via detached subprocess, persistent queue store, agent detection, and updated tests. Most of the code is well-structured and tested. However, there is a syntax error in telemetry-lib.js where a duplicate/orphaned Object.entries filter line was left in, and several previously-raised issues remain unaddressed (broad header denylist, env passthrough to worker, body re-parsing, wire format inconsistency, detectCopilotAgent type guard, redundant mkdirSync in readQueue shrink path).

📝 9 suggestion(s) (1 new, 8 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/telemetry-lib.js Outdated
Comment thread src/telemetry-lib.js
Comment on lines 258 to 272
init: (versionString, binName, remoteConf = {}) => {
global.commandHookStartTime = Date.now()
rootCliVersion = versionString
if (remoteConf.fetchHeaders) {
fetchHeaders = remoteConf.fetchHeaders
}
if (remoteConf.postUrl) {
postUrl = remoteConf.postUrl
}
postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL
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 }
configKey = binName + '-cli-telemetry'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush worker subprocess inherits the full process.env (including potentially sensitive variables like AWS credentials, tokens, etc.) with only AIO_TELEMETRY_DISABLED added. Consider passing a minimal env to the worker instead of spreading all of process.env.

Suggested change
init: (versionString, binName, remoteConf = {}) => {
global.commandHookStartTime = Date.now()
rootCliVersion = versionString
if (remoteConf.fetchHeaders) {
fetchHeaders = remoteConf.fetchHeaders
}
if (remoteConf.postUrl) {
postUrl = remoteConf.postUrl
}
postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL
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 }
configKey = binName + '-cli-telemetry'
postUrl = remoteConf.postUrl || process.env.AIO_TELEMETRY_POST_URL || DEFAULT_TELEMETRY_POST_URL
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()))
)
fetchHeaders = { ...DEFAULT_FETCH_HEADERS, ...extraFetchHeaders }
configKey = binName + '-cli-telemetry'

Comment thread src/telemetry-lib.js Outdated
Comment on lines +216 to +223
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const metrics = JSON.parse(fetchConfig.body)[0].metrics

if (eventType !== 'postrun') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush worker subprocess inherits the full process.env. Consider passing only a minimal environment.

Suggested change
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const metrics = JSON.parse(fetchConfig.body)[0].metrics
if (eventType !== 'postrun') {
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
NODE_PATH: process.env.NODE_PATH,
AIO_TELEMETRY_DISABLED: '1'
},
detached: true,
stdio: 'ignore'
})

Comment thread src/telemetry-lib.js Outdated
Comment on lines +196 to +199
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
}
})
body: JSON.stringify([{
metrics: [{
name: 'aio.cli.telemetry',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The line const metrics = JSON.parse(fetchConfig.body)[0].metrics re-parses JSON that was just serialized. Build the metrics array first and reference it directly to avoid the redundant round-trip.

Suggested change
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
}
})
body: JSON.stringify([{
metrics: [{
name: 'aio.cli.telemetry',
const metricsArray = [{
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 fetchConfig = {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify([{ metrics: metricsArray }])
}
const metrics = metricsArray

Comment thread src/flush-worker.js
Comment on lines +52 to +57
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The header filter only blocks 'api-key'. Broaden to cover 'authorization', 'x-api-key', and 'x-ingest-key' for consistency with telemetry-lib.js.

Suggested change
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js Outdated
Comment on lines +59 to +62
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
currentMetrics = JSON.parse(body)[0].metrics

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] If body is valid JSON but does not contain an array at index 0, or metrics is missing, this throws a TypeError silently swallowed by the outer catch. Add an explicit guard.

Suggested change
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
currentMetrics = JSON.parse(body)[0].metrics
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
currentMetrics = parsedBody[0].metrics

Comment thread src/flush-worker.js
Comment on lines +73 to +76
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush-worker POSTs with body shape { batches: [{ metrics: allMetrics }] } but telemetry-lib.js serializes the body as [{ metrics: [...] }] (a top-level array). These formats are inconsistent. Both sides should agree on the wire format. Change the worker to re-use the same top-level array format.

Suggested change
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify([{ metrics: allMetrics }])
})

Comment thread src/telemetry-lib.js
Comment on lines +30 to +35
* @returns {string|null} Agent name when detected, otherwise null.
*/
function detectCopilotAgent (pathValue) {
if (!pathValue) return null
// Extension id appears in globalStorage paths on all platforms; do not tie to '/' (Windows uses '\').
if (pathValue.includes('github.copilot-chat')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent checks pathValue.includes(...) without guarding against a non-string value. Add a typeof guard to avoid TypeError when PATH is set to a non-string value.

Suggested change
* @returns {string|null} Agent name when detected, otherwise null.
*/
function detectCopilotAgent (pathValue) {
if (!pathValue) return null
// Extension id appears in globalStorage paths on all platforms; do not tie to '/' (Windows uses '\').
if (pathValue.includes('github.copilot-chat')) {
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/queue-store.js Outdated
Comment on lines +89 to +96
/**
* 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<object>} items Flat array of New Relic metric objects.
*/
function writeQueue (items) {
const file = getQueuePath()
const toWrite = normalizeQueueItems(items)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] readQueue writes back a truncated file when it finds an oversized queue, but the mkdirSync call is unnecessary here since the file already exists (readFileSync succeeded). Remove the redundant mkdirSync in the shrink-rewrite path.

Suggested change
/**
* 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<object>} items Flat array of New Relic metric objects.
*/
function writeQueue (items) {
const file = getQueuePath()
const toWrite = normalizeQueueItems(items)
if (parsed.length > normalized.length) {
try {
fs.writeFileSync(file, JSON.stringify(normalized), 'utf8')
} catch {
// same as writeQueue — never throw from telemetry persistence
}
}
return normalized

@github-actions github-actions Bot dismissed their stale review May 14, 2026 17:17

Superseded by new review

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The diff is a substantial refactor introducing a flush-worker subprocess, persistent queue store, agent detection, and improved telemetry payload format. Most of the code is well-structured and tested. However, several previously-raised issues remain unaddressed (syntax error in init, flush worker inheriting full env, inconsistent wire format, redundant JSON round-trip, narrow header blocklist in flush-worker, missing guard in flush-worker body parse, missing typeof guard in detectCopilotAgent, redundant mkdirSync in readQueue shrink path), plus the tests assert the old batches wrapper format which will fail against the suggested fix.

📝 10 suggestion(s) (2 new, 8 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/telemetry-lib.js Outdated
Comment on lines 267 to 273
Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
extraFetchHeaders = Object.fromEntries(
Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
configKey = binName + '-cli-telemetry'
},
getClientId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Syntax error: duplicate/orphaned Object.fromEntries assignment — the closing parenthesis of the first call is missing, then the entire expression is repeated. This is a parse error that will prevent the module from loading.

Suggested change
Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
extraFetchHeaders = Object.fromEntries(
Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
configKey = binName + '-cli-telemetry'
},
getClientId,
extraFetchHeaders = Object.fromEntries(
Object.entries(rawExtra).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
fetchHeaders = { ...DEFAULT_FETCH_HEADERS, ...extraFetchHeaders }
configKey = binName + '-cli-telemetry'

Comment thread src/telemetry-lib.js Outdated
Comment on lines +196 to +198
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
}
})
body: JSON.stringify([{
metrics: [{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The line const metrics = JSON.parse(fetchConfig.body)[0].metrics re-parses JSON that was just serialized. Build the metrics array first and reference it directly.

Suggested change
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
}
})
body: JSON.stringify([{
metrics: [{
const metricsArray = [{
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 fetchConfig = {
method: 'POST',
headers: fetchHeaders,
body: JSON.stringify([{ metrics: metricsArray }])
}
const metrics = metricsArray

Comment thread src/telemetry-lib.js Outdated
Comment on lines +213 to +221
nodeVersion: process.version,
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const metrics = JSON.parse(fetchConfig.body)[0].metrics

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush worker subprocess inherits the full process.env including potentially sensitive variables. Pass only a minimal environment to the worker.

Suggested change
nodeVersion: process.version,
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}]
}])
}
const metrics = JSON.parse(fetchConfig.body)[0].metrics
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
NODE_PATH: process.env.NODE_PATH,
AIO_TELEMETRY_DISABLED: '1'
},
detached: true,
stdio: 'ignore'
})

Comment thread src/telemetry-lib.js
Comment on lines +32 to +36
function detectCopilotAgent (pathValue) {
if (!pathValue) return null
// Extension id appears in globalStorage paths on all platforms; do not tie to '/' (Windows uses '\').
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent does not guard against non-string values. If PATH is set to a non-string (e.g. null, a number injected via a test), pathValue.includes will throw a TypeError.

Suggested change
function detectCopilotAgent (pathValue) {
if (!pathValue) return null
// Extension id appears in globalStorage paths on all platforms; do not tie to '/' (Windows uses '\').
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/flush-worker.js
Comment on lines +54 to +58
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The header filter only blocks 'api-key'. Broaden to cover 'authorization', 'x-api-key', and 'x-ingest-key' to be consistent with telemetry-lib.js.

Suggested change
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js
Comment on lines +59 to +60
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] If the body parses but does not have an array at index 0, or metrics is absent/non-array, this throws a TypeError silently swallowed by the outer catch. Add an explicit guard.

Suggested change
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
currentMetrics = parsedBody[0].metrics

Comment thread src/flush-worker.js Outdated
Comment on lines +73 to +78
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ batches: [{ metrics: allMetrics }] })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush-worker POSTs with body shape { batches: [{ metrics: allMetrics }] } but telemetry-lib.js serializes the body as [{ metrics: [...] }] (a top-level array). Both sides must agree on the wire format. Change the worker to use the same top-level array format to ensure the proxy receives consistent payloads.

Suggested change
try {
debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({ batches: [{ metrics: allMetrics }] })
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify([{ metrics: allMetrics }])
})

Comment thread src/queue-store.js Outdated
Comment on lines +90 to +97
* 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<object>} items Flat array of New Relic metric objects.
*/
function writeQueue (items) {
const file = getQueuePath()
const toWrite = normalizeQueueItems(items)
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The mkdirSync call inside the shrink-rewrite path in readQueue is unnecessary — the file already exists at this point (readFileSync succeeded). Remove it to avoid the redundant directory creation.

Suggested change
* 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<object>} items Flat array of New Relic metric objects.
*/
function writeQueue (items) {
const file = getQueuePath()
const toWrite = normalizeQueueItems(items)
try {
if (parsed.length > normalized.length) {
try {
fs.writeFileSync(file, JSON.stringify(normalized), 'utf8')
} catch {
// same as writeQueue — never throw from telemetry persistence
}
}
return normalized

Comment thread test/flush-worker.test.js Outdated
Comment on lines +72 to +75
expect(writeQueue).not.toHaveBeenCalled()
})

test('writes merged metrics to queue on fetch failure', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Once the wire format inconsistency in flush-worker.js is fixed (top-level array instead of batches wrapper), these assertions need to be updated to match the corrected format.

Suggested change
expect(writeQueue).not.toHaveBeenCalled()
})
test('writes merged metrics to queue on fetch failure', async () => {
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)

Comment thread test/flush-worker.test.js Outdated
Comment on lines +104 to +107
await main()

expect(writeQueue).toHaveBeenCalledTimes(1)
expect(writeQueue).toHaveBeenCalledWith([METRIC])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Update assertion to match corrected top-level array wire format after fixing the batches wrapper inconsistency.

Suggested change
await main()
expect(writeQueue).toHaveBeenCalledTimes(1)
expect(writeQueue).toHaveBeenCalledWith([METRIC])
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)

@github-actions github-actions Bot dismissed their stale review May 14, 2026 17:22

Superseded by new review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The diff addresses the architectural refactor from direct HTTP POST to a detached flush-worker subprocess, adds agent detection, and improves documentation. Most previous re-raised issues remain unaddressed: flush-worker.js still uses the { batches: [...] } wrapper instead of the top-level array format that telemetry-lib.js produces, tests still assert the batches shape, the blocked-header set in flush-worker.js is still too narrow, and the detectCopilotAgent guard still doesn't check typeof, and the body is still re-parsed from JSON right after serialization. Several new minor issues are also present.

📝 8 suggestion(s) (2 new, 6 re-raised)


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
Comment on lines +63 to +66
return
}

try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The flush-worker POSTs with body shape { batches: [...] } but telemetry-lib.js serializes as [{ metrics: [...] }] (a top-level array). The wire format is inconsistent and will cause the proxy to receive malformed payloads. Change to pass the parsed array directly as the body, matching the format telemetry-lib produces.

Suggested change
return
}
try {
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(batches)
})

Comment thread src/flush-worker.js
Comment on lines +46 to +50
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return
}
postUrl = url

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The header filter only blocks 'api-key'. Broaden to cover 'authorization', 'x-api-key', and 'x-ingest-key' to be consistent with telemetry-lib.js.

Suggested change
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js
Comment on lines +53 to +59
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody)) {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] If the body parses but does not have an array at index 0, or metrics is absent/non-array, the batches variable will hold an unexpected shape, causing silent failures or incorrect POSTs. Add an explicit guard.

Suggested change
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody)) {
return
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
batches = parsedBody

Comment thread src/telemetry-lib.js
Comment on lines +30 to +35
*
* @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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent does not guard against non-string values. If PATH is set to null or a non-string (e.g. injected via a test), pathValue.includes will throw a TypeError. Add a typeof guard.

Suggested change
*
* @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
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/telemetry-lib.js Outdated
Comment on lines +218 to +222
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The line const batch = JSON.parse(fetchConfig.body) re-parses JSON that was just serialized two lines above. Build the metrics array first and reference it directly, avoiding the redundant serialization/deserialization round-trip.

Suggested change
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 metricsThisEvent = [{
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'
}
}]

Comment thread src/telemetry-lib.js Outdated
Comment on lines +193 to +217
if (optedOut) {
pendingCommandMetrics.length = 0
debug('Telemetry is off. Not logging telemetry event', eventType)
} else {
const clientId = getClientId()
const timestamp = Date.now()
const invocationContext = getInvocationContext()
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
}
})
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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fetchConfig object is only used to serialize the body, then immediately re-parsed. After applying the suggestion to build metricsThisEvent directly, this entire fetchConfig block can be removed.

Suggested change
if (optedOut) {
pendingCommandMetrics.length = 0
debug('Telemetry is off. Not logging telemetry event', eventType)
} else {
const clientId = getClientId()
const timestamp = Date.now()
const invocationContext = getInvocationContext()
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
}
})
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(),
const timestamp = Date.now()
const invocationContext = getInvocationContext()

Comment thread test/flush-worker.test.js
Comment on lines +58 to +62
})

test('does not throw when fetch rejects', async () => {
fetch.mockRejectedValue(new Error('network error'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Once the wire format inconsistency in flush-worker.js is fixed (top-level array instead of batches wrapper), these assertions need to be updated to match the corrected format.

Suggested change
})
test('does not throw when fetch rejects', async () => {
fetch.mockRejectedValue(new Error('network error'))
const posted = JSON.parse(opts.body)
expect(Array.isArray(posted)).toBe(true)
expect(posted[0].metrics).toHaveLength(1)
expect(posted[0].metrics[0]).toEqual(METRIC)

Comment thread src/telemetry-lib.js
@github-actions github-actions Bot dismissed their stale review May 14, 2026 18:43

Superseded by new review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

Most previously raised issues remain unresolved. The wire-format mismatch ({ batches } wrapper vs top-level array) still exists in both flush-worker.js and the test assertions. The header blocklist in flush-worker.js still only blocks 'api-key'. The parsedBody[0].metrics array guard is absent. The redundant JSON serialize/parse round-trip in telemetry-lib.js is still present. The spawn env still inherits all of process.env including secrets. The detectCopilotAgent non-string guard issue is partially addressed (falsy check added) but a non-string truthy value (e.g. a number or object) would still reach .includes and throw.

🔄 8 re-raised suggestion(s) from previous review


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
Comment on lines +57 to +60
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody)) {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Wire format mismatch: the body is a top-level array (as produced by telemetry-lib.js) but is re-wrapped in { batches } here. The proxy will receive a different shape than what telemetry-lib serializes.

Suggested change
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody)) {
return
}
const res = await fetch(postUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(batches)
})

Comment thread src/flush-worker.js
Comment on lines +47 to +52
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Header filter only blocks 'api-key'. Broaden to cover 'authorization', 'x-api-key', and 'x-ingest-key' for consistency with telemetry-lib.js.

Suggested change
if (!url || typeof url !== 'string') {
return
}
postUrl = url
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js
Comment on lines +53 to +57
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Missing guard: if parsedBody[0] exists but lacks a metrics array the worker silently sends a malformed payload. Add an explicit check.

Suggested change
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
batches = parsedBody

Comment thread src/telemetry-lib.js
Comment thread src/telemetry-lib.js Outdated
Comment on lines +196 to +213
debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend)

if (isDisabledForCommand || config.get(`${configKey}.optOut`, 'global') === true) {
if (optedOut) {
pendingCommandMetrics.length = 0
debug('Telemetry is off. Not logging telemetry event', eventType)
} else {
const clientId = getClientId()
const timestamp = Date.now()
const invocationContext = getInvocationContext()
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
}
})
body: JSON.stringify([{
metrics: [{
name: 'aio.cli.telemetry',
type: 'gauge',
value: 1,
// id: Math.floor(timestamp * Math.random()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The fetchConfig body is serialized to JSON and then immediately re-parsed two lines later. Build the metrics array directly to avoid the redundant round-trip.

Suggested change
debug('trackEvent %s eventData=%j postUrl=%s willSend=%s', eventType, eventData, postUrl, willSend)
if (isDisabledForCommand || config.get(`${configKey}.optOut`, 'global') === true) {
if (optedOut) {
pendingCommandMetrics.length = 0
debug('Telemetry is off. Not logging telemetry event', eventType)
} else {
const clientId = getClientId()
const timestamp = Date.now()
const invocationContext = getInvocationContext()
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
}
})
body: JSON.stringify([{
metrics: [{
name: 'aio.cli.telemetry',
type: 'gauge',
value: 1,
// id: Math.floor(timestamp * Math.random()),
const timestamp = Date.now()
const invocationContext = getInvocationContext()
const metricsThisEvent = [{
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'
}
}]

Comment thread src/telemetry-lib.js Outdated
Comment on lines +214 to +215
timestamp,
attributes: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] These two lines exist only because of the redundant serialize/parse. Remove them after applying the suggestion above.

Comment thread src/telemetry-lib.js Outdated
Comment thread test/flush-worker.test.js
Comment on lines +62 to +66

process.argv = ['node', 'flush-worker.js', flushArg()]
await expect(main()).resolves.toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Test assertions still expect the { batches } wrapper shape. Once the wire format is fixed to use a top-level array, update these assertions accordingly.

Suggested change
process.argv = ['node', 'flush-worker.js', flushArg()]
await expect(main()).resolves.toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
})
const posted = JSON.parse(opts.body)
expect(Array.isArray(posted)).toBe(true)
expect(posted[0].metrics).toHaveLength(1)
expect(posted[0].metrics[0]).toEqual(METRIC)

@github-actions github-actions Bot dismissed their stale review May 14, 2026 19:20

Superseded by new review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The re-raised issues from the previous review remain unresolved: the flush worker still wraps the payload in { batches } instead of posting the top-level array, the header filter still only blocks 'api-key', the malformed-payload guard is missing the metrics-array check, detectCopilotAgent lacks a typeof string guard, and the spawned worker still inherits all of process.env. The test assertions also still expect the { batches } wrapper shape. All other new code (agent detection, batching, env-var disable, formatEventDataAttribute, resolveEventData) is well-structured and thoroughly tested.

🔄 6 re-raised suggestion(s) from previous review


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
return
}
batches = parsedBody
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Wire format mismatch: body is a top-level array but is re-wrapped in { batches }. Post the parsed array directly.

Suggested change
} catch {
body: JSON.stringify(batches)

Comment thread src/flush-worker.js
Comment on lines +44 to +47
try {
const parsed = JSON.parse(process.argv[2])
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Header filter only blocks 'api-key'. Broaden to cover authorization, x-api-key, and x-ingest-key.

Suggested change
try {
const parsed = JSON.parse(process.argv[2])
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js
Comment on lines +53 to +55
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Missing guard: if parsedBody[0] exists but lacks a metrics array the worker silently sends a malformed payload. Add an explicit check.

Suggested change
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
batches = parsedBody

Comment thread src/telemetry-lib.js
Comment on lines +43 to +44
return 'github-copilot'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent does not guard against non-string truthy values. A non-string (e.g. number, object) would reach .includes and throw TypeError.

Suggested change
return 'github-copilot'
}
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/telemetry-lib.js
Comment on lines +213 to +221
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}

if (eventType !== 'postrun') {
pendingCommandMetrics.push(metric)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The spawned worker inherits all of process.env including secrets and tokens. Pass only the minimal env vars needed.

Suggested change
osNameVersion,
invocation_context: /* istanbul ignore next */ invocationContext.isAgent ? 'agent' : 'human',
agent_name: /* istanbul ignore next */ invocationContext.agentName || 'unknown'
}
}
if (eventType !== 'postrun') {
pendingCommandMetrics.push(metric)
return
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
NODE_PATH: process.env.NODE_PATH,
AIO_TELEMETRY_DISABLED: '1'
},
detached: true,
stdio: 'ignore'
})

Comment thread test/flush-worker.test.js
Comment on lines +63 to +67
process.argv = ['node', 'flush-worker.js', flushArg()]
await expect(main()).resolves.toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Test assertions expect { batches } wrapper. Once wire format is fixed to top-level array, update accordingly.

Suggested change
process.argv = ['node', 'flush-worker.js', flushArg()]
await expect(main()).resolves.toBeUndefined()
expect(fetch).toHaveBeenCalledTimes(1)
})
const posted = JSON.parse(opts.body)
expect(Array.isArray(posted)).toBe(true)
expect(posted[0].metrics).toHaveLength(1)
expect(posted[0].metrics[0]).toEqual(METRIC)

@github-actions github-actions Bot dismissed their stale review May 14, 2026 19:44

Superseded by new review

mgar added 4 commits June 8, 2026 10:29
…A-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.
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.
feat: make telemetry opt-out with a one-time notice

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 PR Reviewer

The refactor is substantial and well-structured: removing the Splunk/inquirer dependencies, introducing a detached flush worker, buffering non-postrun metrics, and adding agent detection. Tests are comprehensive. Three previously-raised issues remain unresolved: the wire format wraps the parsed array in { batches } instead of posting it directly, the header blocklist only filters api-key, and the flush-worker guard for a missing metrics array is absent. The detectCopilotAgent function also still lacks a typeof string guard, and the spawned worker inherits all of process.env.

🔄 6 re-raised suggestion(s) from previous review


💡 How to re-trigger

Comment /review or /pr-reviewer on this PR

Comment thread src/flush-worker.js
return
}
batches = parsedBody
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Wire format mismatch: the parsed array is re-wrapped in { batches }. The proxy expects a bare array (same shape as mergedBody in telemetry-lib). Post it directly.

Suggested change
} catch {
body: JSON.stringify(batches)

Comment thread src/flush-worker.js
Comment on lines +44 to +48
try {
const parsed = JSON.parse(process.argv[2])
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Header filter only blocks 'api-key'. Broaden the blocklist to cover common secret header names.

Suggested change
try {
const parsed = JSON.parse(process.argv[2])
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
return
if (headers && typeof headers === 'object') {
const BLOCKED_HEADERS = new Set(['api-key', 'authorization', 'x-api-key', 'x-ingest-key'])
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => !BLOCKED_HEADERS.has(key.toLowerCase()))
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}

Comment thread src/flush-worker.js
Comment on lines +51 to +54
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Missing guard: if the parsed array exists but its first element lacks a metrics array the worker silently sends a malformed payload.

Suggested change
if (headers && typeof headers === 'object') {
const safe = Object.fromEntries(
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
)
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
batches = parsedBody

Comment thread src/telemetry-lib.js
Comment on lines +44 to +47
return null
}

// TODO: detect VSCODE run as an agent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] detectCopilotAgent does not guard against non-string truthy values. A non-string value (e.g. a number injected via a test env object) would reach .includes and throw TypeError.

Suggested change
return null
}
// TODO: detect VSCODE run as an agent
function detectCopilotAgent (pathValue) {
if (!pathValue || typeof pathValue !== 'string') return null
if (pathValue.includes('github.copilot-chat')) {
return 'github-copilot'
}
return null
}

Comment thread src/telemetry-lib.js
Comment on lines +207 to +213
eventType,
eventData: formatEventDataAttribute(eventData),
cliVersion: rootCliVersion,
clientId,
command: prerunEvent.command,
commandDuration: timestamp - prerunEvent.start,
commandFlags: prerunEvent.flags.toString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] The spawned worker inherits all of process.env, including secrets and tokens present in the CLI environment. Pass only the minimal set of vars the worker actually needs.

Suggested change
eventType,
eventData: formatEventDataAttribute(eventData),
cliVersion: rootCliVersion,
clientId,
command: prerunEvent.command,
commandDuration: timestamp - prerunEvent.start,
commandFlags: prerunEvent.flags.toString(),
const child = spawn(process.execPath, [path.join(__dirname, 'flush-worker.js'), flushPayload], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
NODE_PATH: process.env.NODE_PATH,
AIO_TELEMETRY_DISABLED: '1'
},
detached: true,
stdio: 'ignore'
})

Comment thread test/flush-worker.test.js
Comment on lines +57 to +60
expect(posted.batches[0].metrics[0]).toEqual(METRIC)
})

test('does not throw when fetch rejects', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Re-raised] Test assertions expect the { batches } wrapper. Once the wire format is fixed to post the bare array, update these assertions accordingly.

Suggested change
expect(posted.batches[0].metrics[0]).toEqual(METRIC)
})
test('does not throw when fetch rejects', async () => {
const posted = JSON.parse(opts.body)
expect(Array.isArray(posted)).toBe(true)
expect(posted[0].metrics).toHaveLength(1)
expect(posted[0].metrics[0]).toEqual(METRIC)

@github-actions github-actions Bot dismissed their stale review June 9, 2026 16:48

Superseded by new review

Comment thread src/telemetry-lib.js
Comment on lines +222 to +223
if (eventType !== 'postrun') {
pendingCommandMetrics.push(metric)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Flagged by claude:

in oclif, when a command throws, the command_error hook fires and the process exits — the postrun hook does not run. Same for command_not_found. So under the new design, error/not-found telemetry is buffered and then silently dropped on exit. That's a functional regression: the previous code POSTed each event immediately, so failures were actually captured. With this change, error telemetry (arguably the most valuable signal) effectively disappears.

The tests mask this because they manually call trackEvent('postrun') right after the error hook to force a flush — e.g. test/hooks.test.js "command-error" — which doesn't reflect the real oclif lifecycle where no postrun follows an error.

Suggestion: flush terminal events (command-error, command-not-found) immediately rather than buffering them, or have the command_error hook trigger a flush.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@pru55e11 I'm working on a fix in a new PR. I will post the link here shortly

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I opened PR #41 to address this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants