Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
81 changes: 63 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,76 @@ _See code: [src/commands/telemetry/index.js](https://github.com/adobe/aio-cli-pl
## Configuration
The following values need to be set when this plugin is hosted by different CLIs
- `aioTelemetry`: defined object in root cli package.json with values:
- `postUrl` : Where to post telemetry data
- `postHeaders`: Any specific headers that need to be posted with telemetry data (ex. x-api-key)
- `postUrl` : Where to post telemetry data (overrides the default New Relic ingest endpoint)
- `fetchHeaders`: Headers to send with the telemetry POST request (overrides the default New Relic ingest headers)
- `productPrivacyPolicyLink`: A link to display to users when prompting to optIn
- `productName`: How to refer to the cli when user is prompted to enable telemetry
- this value is read from `displayName` or `name` of the cli's package.json
- `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
## Opting out via environment variable

Set `AIO_TELEMETRY_DISABLED=1` (or any truthy value) to suppress all telemetry without modifying the persisted opt-in state. Useful for CI pipelines and scripted environments.

Here is an example of the event data as posted:
```sh
AIO_TELEMETRY_DISABLED=1 aio app deploy
```
{ "id": 656915165813,
"timestamp": 1673404918437,
"_adobeio": {
"eventType": "telemetry-prompt",
"eventData": "accepted",
"cliVersion": "@adobe/aio-cli@9.1.1",
"clientId": 264421030538,
"commandDuration": 5661,
"commandFlags": "",
"commandSuccess": true,
"nodeVersion": "v14.20.0",
"osNameVersion": "macOS"
}
}

## Flush architecture

Telemetry events are sent via a **fire-and-forget detached subprocess** (`src/flush-worker.js`). The parent CLI process spawns the worker and immediately unrefs it, so the CLI exits at its normal time without waiting for the HTTP POST to complete. The worker owns the ingest endpoint and credentials.

## Agent detection

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

Supported agents detected automatically:

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

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

## POST data

Events are posted to the New Relic Metric API. Here is an example of the payload:

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

/**
* Telemetry flush worker — spawned as a detached subprocess by trackEvent so
* the parent process can exit immediately without waiting on the HTTP POST.
*
* Accepts a single CLI argument: a JSON-encoded object with shape { body: string }
* where body is a serialised New Relic metric payload (array of metric batches).
*
* On each run the worker merges any previously-failed events from the persistent
* queue (src/queue-store.js) with the current event before POSTing. On success
* the queue is cleared; on failure the merged set is written back so the next
* invocation can retry.
*/

'use strict'

const { createFetch } = require('@adobe/aio-lib-core-networking')
const { readQueue, writeQueue, clearQueue } = require('./queue-store')

const fetch = createFetch()

const POST_URL = 'https://metric-api.newrelic.com/metric/v1'
const FETCH_HEADERS = {
'Content-Type': 'application/json',
// New Relic ingest key — write-only, cannot read data or access any other system.
'Api-Key': 'd6b73f9c1859dc462e6de8dee3de1eb2FFFFNRAL'
}

/**
* Reads the persistent queue, merges it with the current event, POSTs the batch,
* and either clears the queue on success or writes back on failure for retry.
* @returns {Promise<void>}
*/
async function main () {
// Parse the current event payload passed by the parent process.
let currentMetrics
try {
const { body } = JSON.parse(process.argv[2])
currentMetrics = JSON.parse(body)[0].metrics
} catch {
// Malformed argument — nothing useful to do.
return
}

// Merge previously-queued metrics (if any) with the current event so they
// are all retried in a single POST.
const queuedMetrics = readQueue()
const allMetrics = [...queuedMetrics, ...currentMetrics]

try {
await fetch(POST_URL, {
method: 'POST',
headers: FETCH_HEADERS,
body: JSON.stringify([{ 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)
}
}

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

module.exports = { main }
81 changes: 81 additions & 0 deletions src/queue-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Copyright 2026 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

/**
* Persistent queue store for telemetry events that failed to POST.
*
* The queue is kept in a dedicated JSON file that lives alongside the main aio
* config directory but is completely separate from user-visible aio configuration:
*
* ${XDG_CONFIG_HOME:-~/.config}/aio/.telemetry-queue.json
*
* On the next CLI invocation the flush worker picks up any queued events, merges
* them with the new event, and retries the batch. On success the file is removed.
*/

'use strict'

const fs = require('fs')
const path = require('path')
const os = require('os')

/**
* Resolves the absolute path to the queue file, honouring XDG_CONFIG_HOME when set.
* @returns {string} Absolute path to .telemetry-queue.json.
*/
function getQueuePath () {
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
return path.join(base, 'aio', '.telemetry-queue.json')
}

/**
* Reads the current queue from disk.
* Returns an empty array when the file does not exist or is unreadable.
* @returns {Array<object>} Flat array of New Relic metric objects.
*/
function readQueue () {
try {
const data = fs.readFileSync(getQueuePath(), 'utf8')
const parsed = JSON.parse(data)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}

/**
* Persists the given metrics array to the queue file, creating directories as needed.
* Silently ignores write errors — telemetry must never crash the CLI.
* @param {Array<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
}
}

/**
* Removes the queue file.
* Silently ignores errors (e.g. the file does not exist).
*/
function clearQueue () {
try {
fs.unlinkSync(getQueuePath())
} catch {
// silently ignore — queue file may not exist
}
}

module.exports = { getQueuePath, readQueue, writeQueue, clearQueue }
Loading
Loading