Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
983a78c
feat: enhance telemetry-lib with agent detection and context tracking
purplecabbage Mar 10, 2026
95feec5
more post to worker to not delay command
purplecabbage Apr 7, 2026
aca78ed
feat(telemetry): fire-and-forget flush, retry queue
purplecabbage Apr 7, 2026
66106d5
fix linting/jsdoc
purplecabbage Apr 7, 2026
392fa15
fix: windows osname issues, removed
purplecabbage Apr 7, 2026
0b274ea
post to a proxy
purplecabbage May 9, 2026
3e1701d
remove extra logging flag
purplecabbage May 9, 2026
4672101
Merge branch 'master' into telemetry-proxy
purplecabbage May 9, 2026
6a6df57
remove newrelic leftovers
purplecabbage May 9, 2026
e2a56c7
catch spawn errors
purplecabbage May 9, 2026
fdc10ae
sanitize mixed case headers
purplecabbage May 11, 2026
b5f6072
worker checks for ok before clearing queue
purplecabbage May 11, 2026
c0acdfb
only flush queue on postrun, 1 command can produce multiple events, b…
purplecabbage May 11, 2026
e40f5bd
document override of telemetry postUrl
purplecabbage May 11, 2026
602deee
fix: formatting of eventData was inconsistent
purplecabbage May 11, 2026
cbe9afe
fix:guard non-string value in pathValue
purplecabbage May 11, 2026
df0001c
update docs
purplecabbage May 13, 2026
8726ccb
cap metric count for long offline periods
purplecabbage May 13, 2026
42a6e26
fix: windows path. simpler copilot detection
purplecabbage May 13, 2026
11b631f
remve redundant mkdirSync
purplecabbage May 14, 2026
08f9101
header denylist
purplecabbage May 14, 2026
dadbd2b
Fix error introduced by copilot
purplecabbage May 14, 2026
3125ba8
simplify to best-effort tracking
purplecabbage May 14, 2026
dfa8573
accept disabled=yes|true|1, do not override flush worker env
purplecabbage May 14, 2026
16cb9d9
Removing the redundant pattern: fetchConfig mimicked an HTTP request …
purplecabbage May 14, 2026
e3387c7
feat(telemetry): make telemetry opt-out with a one-time notice
mgar Jun 8, 2026
aa5cee1
feat(telemetry): point default telemetry URL at production proxy (ACN…
mgar Jun 8, 2026
abaacfc
chore(telemetry): interpolate trackEvent debug line so values show un…
mgar Jun 8, 2026
e1f0b38
refactor(telemetry): simplify one-time notice per review
mgar Jun 8, 2026
718b851
Merge pull request #40 from adobe/miguel/feat/acna-4599-opt-out
mgar Jun 9, 2026
1df8142
fix(telemetry): flush on terminal events so error telemetry is delivered
mgar Jun 9, 2026
a7ca31f
fix(telemetry): de-dupe the typo path without masking nested-command …
mgar Jun 9, 2026
4b6cc7a
fix(telemetry): emit event type as non-reserved attribute `eventName`
mgar Jun 10, 2026
be2bc1e
Merge pull request #41 from adobe/miguel/fix/flush-terminal-telemetry…
mgar Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 108 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,116 @@ _See code: [src/commands/telemetry/index.js](https://github.com/adobe/aio-cli-pl
<!-- commandsstop -->

## Configuration
The following values need to be set when this plugin is hosted by different CLIs
- `aioTelemetry`: defined object in root cli package.json with values:
- `postUrl` : Where to post telemetry data
- `postHeaders`: Any specific headers that need to be posted with telemetry data (ex. x-api-key)
- `productPrivacyPolicyLink`: A link to display to users when prompting to optIn
- `productName`: How to refer to the cli when user is prompted to enable telemetry
- this value is read from `displayName` or `name` of the cli's package.json
- `productBin`: Output in help text
- ex. To turn telemetry on run `${productBin} telemetry on`
- this value is read from 'bin' of the cli's package.json, if the package exports more than 1 bin the first is used

## POST data
When this plugin is hosted by different CLIs:

Here is an example of the event data as posted:
```
{ "id": 656915165813,
"timestamp": 1673404918437,
"_adobeio": {
"eventType": "telemetry-prompt",
"eventData": "accepted",
"cliVersion": "@adobe/aio-cli@9.1.1",
"clientId": 264421030538,
"commandDuration": 5661,
"commandFlags": "",
"commandSuccess": true,
"nodeVersion": "v14.20.0",
"osNameVersion": "macOS"
- `aioTelemetry` (optional object in the root `package.json` of the **host CLI** — the same `pjson` oclif passes into the init hook):
- `postUrl` (optional string): HTTPS URL of the telemetry proxy that receives POSTed metric batches. Use this when your CLI should send telemetry to a different App Builder action or gateway than the plugin default.
- `fetchHeaders`: Optional extra headers merged into telemetry requests (`Content-Type` is always set)
Comment thread
purplecabbage marked this conversation as resolved.
- `productPrivacyPolicyLink`: A link shown in the one-time telemetry notice (what is collected and how to opt out)
- `productName`: How to refer to the CLI in the telemetry notice (from `displayName` or `name` in `package.json`)
- `productBin`: Shown in help text (from `bin` in `package.json`; if several bins exist, the first is used). Example: run `${productBin} telemetry on`

Comment on lines +45 to +49
### Overriding the telemetry POST URL

Resolution order (first match wins):

1. **`aioTelemetry.postUrl`** in the host CLI `package.json`
2. **`AIO_TELEMETRY_POST_URL`** environment variable (non-empty string)
3. **Built-in default** in the plugin (`DEFAULT_TELEMETRY_POST_URL` in [`src/telemetry-lib.js`](https://github.com/adobe/aio-cli-plugin-telemetry/blob/master/src/telemetry-lib.js))

Host `package.json` example:

```json
{
"name": "my-cli",
"bin": { "mycli": "./bin/run.js" },
"aioTelemetry": {
"postUrl": "https://<namespace>-<project>.adobeio-static.net/api/v1/web/<package>/<action>"
}
}
```

Environment override (no `package.json` change; useful for CI, staging, or local proxy debugging):

```sh
export AIO_TELEMETRY_POST_URL='https://<namespace>-<project>.adobeio-static.net/api/v1/web/<package>/<action>'
mycli app deploy
```

The resolved URL is passed to the flush worker on each telemetry send; it applies for the rest of that CLI process after `init` runs.

## Opting out via environment variable

Telemetry is suppressed when `AIO_TELEMETRY_DISABLED` is set to one of **`true`**, **`1`**, or **`yes`** (exact match; case-sensitive). Other values such as `0`, `false`, `no`, or an empty string do **not** disable telemetry via this variable.

This does not change the persisted opt-out state. Useful for CI pipelines and scripted environments.

```sh
AIO_TELEMETRY_DISABLED=true aio app deploy
AIO_TELEMETRY_DISABLED=1 aio app deploy
AIO_TELEMETRY_DISABLED=yes aio app deploy
```
Comment thread
purplecabbage marked this conversation as resolved.

## Flush architecture

Telemetry is **best-effort**: events are not persisted when the proxy is down or the network fails.

A flush happens on a **terminal event** — `postrun` (command succeeded), `command-error` (command threw), or `command-not-found`. oclif runs `postrun` only on success, so `command-error`/`command-not-found` must flush on their own; otherwise error telemetry (the most valuable signal) would be buffered and silently dropped on exit. On a terminal event, any in-memory metrics from earlier hooks in the same command are merged with the terminal metric and the combined batch is handed off to a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI spawns the worker and immediately `unref()`s it, so the CLI can exit without waiting for the HTTP POST. If the POST fails (network error or non-2xx response), the batch is dropped; telemetry must not block or slow normal CLI use.

Non-terminal events (for example `telemetry-notice`) are held in an **in-memory buffer** until the next terminal event flushes them. If the process exits before any terminal event (crash, `SIGKILL`), buffered events are lost. The buffer is cleared when telemetry is disabled or when `init` runs again (new command session).

## Agent detection

The plugin detects whether the CLI is being invoked by an AI agent or a human by inspecting environment variables at the time of the event. The detected context is included in every event as `invocation_context` (`"agent"` or `"human"`) and `agent_name`.

Supported agents detected automatically:

| Environment variable | Detected agent name |
|---|---|
| `AGENT` | value of the variable (or `"generic"` if `1`/`true`) |
| `AI_AGENT` | value of the variable (or `"generic"` if `1`/`true`) |
| `AIO_AGENT` | `aio-opt-in` |
| `AIO_INVOCATION_CONTEXT=agent` | `aio-opt-in` |
| `CURSOR_AGENT` | `cursor` |
| `CLAUDECODE` / `CLAUDE_CODE` | `claude` |
| `GEMINI_CLI` | `gemini` |
| `CODEX_SANDBOX` | `codex` |
| `AUGMENT_AGENT` | `augment` |
| `CLINE_ACTIVE` | `cline` |
| `OPENCODE_CLIENT` | `opencode` |
| `REPL_ID` | `replit` |
| `PATH` containing `github.copilot-chat` | `github-copilot` |

To opt into agent tracking without setting a tool-specific variable, set `AIO_INVOCATION_CONTEXT=agent`.

## POST data

The `eventData` attribute is always a string. Objects and arrays are stored as a JSON text (e.g. `"{}"`, `"{\"message\":\"…\"}"`). String payloads (such as the telemetry notice outcome `shown`) are stored as that plain text without an extra layer of JSON quoting. Numbers and booleans use their usual string forms (`"0"`, `"false"`).

Example shape of the metric payload:

```json
[{
"metrics": [{
"name": "aio.cli.telemetry",
"type": "gauge",
"value": 1,
"timestamp": 1673404918437,
"attributes": {
"eventName": "postrun",
"eventData": "{}",
"cliVersion": "@adobe/aio-cli@11.0.2",
"clientId": 264421030538,
"command": "app:deploy",
"commandDuration": 5661,
"commandFlags": "",
"commandSuccess": true,
"nodeVersion": "v22.21.1",
"osNameVersion": "macOS Sequoia 15.4",
"invocation_context": "human",
"agent_name": "unknown"
}
}]
}]
```
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@
"@adobe/aio-lib-core-networking": "^5.0.4",
"@oclif/core": "^4",
"ci-info": "^4.0.0",
"debug": "^4.1.1",
"inquirer": "^8.2.1",
"os-name": "^4.0.1",
"splunk-logging": "^0.11.1"
"debug": "^4.1.1"
},
"devDependencies": {
"@adobe/eslint-config-aio-lib-config": "5.0.0",
Expand Down
87 changes: 87 additions & 0 deletions src/flush-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

/**
* Telemetry flush worker — spawned as a detached subprocess so the parent CLI can
* exit immediately without waiting on the HTTP POST.
*
* Accepts a single CLI argument: a JSON-encoded object with shape
* { body: string, postUrl: string, headers?: object } where `body` is a serialised
* New Relic metric batch array (same shape as the parent builds for fetch), `postUrl`
* is the App Builder proxy, and `headers` (when present) are optional overrides merged
* after the worker defaults (never pass secrets such as api-key).
*
* Failed deliveries are dropped; telemetry is best-effort and must not affect the CLI.
*/

'use strict'

const debug = require('debug')('aio-telemetry:flush-worker')
const { createFetch } = require('@adobe/aio-lib-core-networking')

const fetch = createFetch()

const DEFAULT_HEADERS = {
'Content-Type': 'application/json'
}

/**
* POSTs the metric batch from argv. Swallows all errors.
* @returns {Promise<void>}
*/
async function main () {
let batches
let postUrl
let requestHeaders = { ...DEFAULT_HEADERS }
try {
const parsed = JSON.parse(process.argv[2])
const { body, postUrl: url, headers } = parsed
if (!url || typeof url !== 'string') {
Comment on lines +44 to +47

Copy link
Copy Markdown
Contributor

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 }
}

return
Comment on lines +44 to +48

Copy link
Copy Markdown
Contributor

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 }
}

}
postUrl = url
Comment on lines +46 to +50

Copy link
Copy Markdown
Contributor

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 { 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 on lines +46 to +50

Copy link
Copy Markdown
Contributor

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 }
}

if (headers && typeof headers === 'object') {
Comment on lines +46 to +51

Copy link
Copy Markdown
Contributor

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 }

Copy link
Copy Markdown
Contributor

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
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 safe = Object.fromEntries(

Copy link
Copy Markdown
Contributor

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(
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 }

Copy link
Copy Markdown
Contributor

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 on lines +47 to +52

Copy link
Copy Markdown
Contributor

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 on lines +47 to +52

Copy link
Copy Markdown
Contributor

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 }
}

Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'api-key')
Comment on lines +50 to +53

Copy link
Copy Markdown
Contributor

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 on lines +49 to +53

Copy link
Copy Markdown
Contributor

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
}
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 on lines +49 to +53

Copy link
Copy Markdown
Contributor

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 on lines +47 to +53

Copy link
Copy Markdown
Contributor

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
if (!url || typeof url !== 'string') {
return
}
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 }
}

)

Copy link
Copy Markdown
Contributor

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, giving no indication of the structural problem. Add an explicit guard.

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

Comment on lines +51 to +54

Copy link
Copy Markdown
Contributor

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

requestHeaders = { ...DEFAULT_HEADERS, ...safe }

Copy link
Copy Markdown
Contributor

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 on lines +53 to +55

Copy link
Copy Markdown
Contributor

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 on lines +54 to +55

Copy link
Copy Markdown
Contributor

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 on lines +53 to +55

Copy link
Copy Markdown
Contributor

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 on lines +51 to +56
Comment on lines +51 to +56
const parsedBody = JSON.parse(body)
Comment on lines +53 to +57

Copy link
Copy Markdown
Contributor

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

if (!Array.isArray(parsedBody)) {
Comment on lines +54 to +58

Copy link
Copy Markdown
Contributor

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
)
requestHeaders = { ...DEFAULT_HEADERS, ...safe }
}
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody)) {
const parsedBody = JSON.parse(body)
if (!Array.isArray(parsedBody) || !parsedBody[0] || !Array.isArray(parsedBody[0].metrics)) {
return
}
batches = parsedBody

return
Comment on lines +53 to +59

Copy link
Copy Markdown
Contributor

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 on lines +57 to +60

Copy link
Copy Markdown
Contributor

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)
})

batches = parsedBody
} catch {

Copy link
Copy Markdown
Contributor

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, giving no indication of the structural problem. Add an explicit guard.

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

Comment on lines +56 to +62

Copy link
Copy Markdown
Contributor

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, giving no indication of the structural problem. Add an explicit guard.

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

Copy link
Copy Markdown
Contributor

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, giving no indication of the structural problem. Add an explicit guard.

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

Copy link
Copy Markdown
Contributor

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)

Copy link
Copy Markdown
Contributor

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)

return
}

Copy link
Copy Markdown
Contributor

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
body: JSON.stringify(batches)

try {
Comment on lines +63 to +66

Copy link
Copy Markdown
Contributor

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)
})

debug('POST %s requestHeaders=%o', postUrl, requestHeaders)
const res = await fetch(postUrl, {
method: 'POST',

Copy link
Copy Markdown
Contributor

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 on lines +66 to +69

Copy link
Copy Markdown
Contributor

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 }])
})

headers: requestHeaders,
body: JSON.stringify({ batches })
})
if (!res?.ok) {
const status = res?.status ?? 'unknown'
debug('telemetry flush non-ok: HTTP %s', status)
}
} catch (err) {
debug('telemetry flush failed: %O', err)
}
Comment on lines +66 to +79
}

/* istanbul ignore next */
if (require.main === module) {
main()
}

module.exports = { main }
13 changes: 6 additions & 7 deletions src/hooks/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,16 @@ module.exports = async function (opts) {
opts.argv.filter(arg => arg.indexOf('-') === 0).join(','),
global.prerunTimer)

// init event does not post telemetry, it stores some info that will be used later
// this will prompt to optIn/Out if telemetry.optIn is undefined
// Telemetry is opt-out (on by default). On the first run we show a one-time,
// non-blocking notice instead of an opt-in prompt. isNull() is true only until the
// notice records the default, so this shows once.
if ((opts.argv.indexOf('--no-telemetry') < 0) &&
!inCI &&
telemetryLib.isNull()) {
// let's ask!
// unfortunately the `oclif-dev readme` run by prepack fires this event, which hangs CI
// Also we don't prompt for telemetry if the first command is a telemetry command because they
// are probably setting it on or off already
// skip in CI (handled above) and when oclif-dev readme runs (it fires this event);
// also skip for telemetry commands, where the user is already setting state.
if (['readme', 'telemetry'].indexOf(opts.id) < 0) {
return telemetryLib.prompt(productName, binName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink)
telemetryLib.notice(productName, opts?.config?.pjson?.aioTelemetry?.productPrivacyPolicyLink)
}
}
}
Loading
Loading