Skip to content
Merged
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
26 changes: 18 additions & 8 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
# TUI usage telemetry
# Telemetry

MCode's TUI usage telemetry is disabled by default. No business telemetry client is created and no business telemetry request is sent until the user opts in.
All automatic telemetry uploads are disabled by default and each requires its own opt-in: TUI usage events (`telemetry.enabled`), runtime performance metrics (`telemetry.metrics`), and automatic error diagnostics (`telemetry.diagnostics`). Opting in to usage events does not authorize the other two channels. Login, model requests, update checks, and user-submitted feedback are separate product actions and are not controlled by these switches.

## Turn it on or off

Add this to the active profile's `config.yaml`, normally `~/.minimax-code/config.yaml`, then restart MCode:

```yaml
telemetry:
enabled: true
enabled: true # TUI usage events (anonymous, described below)
metrics: false # Runtime counters, gauges, and histograms
diagnostics: false # Account-linked TUI and LLM error diagnostics
```

Remove the setting or set it to `false` to turn reporting off. Either environment variable below also turns it off and takes precedence over the config file:
Remove a setting or set it to `false` to turn that channel off. Either environment variable below turns **all channels** off and takes precedence over the config file:

```sh
MCODE_DISABLE_TELEMETRY=1 mcode
DO_NOT_TRACK=1 mcode
```

Inspect the effective setting with `mcode telemetry status`. Run `mcode telemetry preview` to see a representative decoded request. Preview does not send a request. When telemetry is disabled, preview shows `request: null`.
Inspect the effective setting of every channel with `mcode telemetry status`. Run `mcode telemetry preview` to see a representative decoded usage-event request. Preview does not send a request. When usage telemetry is disabled, preview shows `request: null`, independently of the metrics and diagnostics settings.

## Data sent
## Usage-event data sent

The HTTP body is `application/x-www-form-urlencoded` with two fields:

Expand Down Expand Up @@ -68,7 +70,7 @@ MCode does not send account IDs, device IDs, workspace paths or names, session I

As with any network request, the receiving server can observe transport metadata such as the source IP address. The client does not add that value to the event payload. `mcode telemetry preview` displays the decoded envelope.

## Destinations
## Usage-event destinations

The destination depends on region and build environment:

Expand All @@ -79,4 +81,12 @@ The destination depends on region and build environment:

The client keeps pending events only in memory and does not write them to disk. This repository does not define or verify server-side retention. Keep telemetry disabled when that policy does not meet your requirements.

This page covers TUI usage telemetry. Login, model requests, update checks, user-submitted feedback, and bounded error diagnostics have separate network behavior described in [TUI capability coverage](tui-capabilities.md).
## Runtime performance metrics

`telemetry.metrics: true` enables the built-in cloud metrics transport: metric names, timestamps, counter/gauge/histogram values, and low-cardinality labels (runtime owner and mode, version, and per-instrument dimensions such as model, tool, or outcome). No account credential is attached. Production destinations are `https://agent.minimax.cn/matrix/api/v1/metrics/batch` (China) and `https://agent.minimax.io/matrix/api/v1/metrics/batch` (global). Metrics stay in memory; when the channel is disabled, no cloud reporter is created.

## Automatic error diagnostics

`telemetry.diagnostics: true` authorizes both TUI incident reports and LLM request-failure reports. Both additionally require a signed-in account: the transport uses the account's Bearer token and a `user_id` query parameter, so these reports are **account-linked** even though their contents are minimized and encrypted. The minimization schemas are described in [TUI capability coverage](tui-capabilities.md#diagnostic-upload-privacy). Both use `/minimax-cloud/api/v1/observability/desktop-errors/batch` on the regional MiniMax host. When the channel is disabled, TUI incidents are written as local-only files (7 days / 200 files) that are never uploaded, and LLM failure reports are dropped before buffering.

Server-side retention for any channel is not defined or verified by this repository. Login, model requests, update checks, and user-submitted feedback have separate network behavior described in [TUI capability coverage](tui-capabilities.md).
4 changes: 2 additions & 2 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from
| Managed connectors | Cloud client, permissions, and invocation adapters restored | Connector runtime and cloud transport tests; actual tool discovery passed; business writes not run |
| Updates | Update entry points, install-source detection, and signature verification restored; public packages use public npm | Update application and service tests; no real installation / upgrade on the development machine |
| Feedback and diagnostic uploads | Reviewed feedback text and minimized diagnostic summaries | Synthetic fixtures exercise session collection and capture/decode the final ZIP upload; no live uploads or real content |
| Automatic LLM error reports | Bounded diagnostic facts minimized before encryption | Synthetic provider errors and native Headers; final HTTP batches captured and decrypted locally |
| Telemetry | Disabled by default; explicit opt-in with environment overrides; account, device, workspace, session, model, and command identifiers removed | Privacy regression tests intercept and decode requests locally; no live upload |
| Automatic LLM error reports | Disabled by default; requires `telemetry.diagnostics` opt-in; bounded diagnostic facts minimized before encryption | Synthetic provider errors and native Headers; final HTTP batches captured and decrypted locally |
| Telemetry | Usage, metrics, and diagnostics each disabled by default with separate opt-ins; `MCODE_DISABLE_TELEMETRY` / `DO_NOT_TRACK` override all channels; usage-event identifiers removed | Privacy regression tests intercept and decode requests locally; no live upload |
| Auto permissions | Cloud classifier restored; local rules and confirmation on failure retained | Classifier client, permission facade, and sandbox tests |
| Model catalog | Online catalog and bundled snapshot fallback restored | Actual build boundary checks and offline startup; online catalog contents not accepted |
| Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests |
Expand Down
8 changes: 7 additions & 1 deletion packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,10 @@ export interface ReviewConfig {
export interface TelemetryConfig {
/** Send anonymous TUI usage events. Disabled until the user opts in. */
enabled: boolean;
/** Send runtime performance metrics. Separate opt-in, disabled by default. */
metrics?: boolean;
/** Send minimized, account-linked automatic error reports. Separate opt-in, disabled by default. */
diagnostics?: boolean;
}

export interface Config {
Expand Down Expand Up @@ -1765,7 +1769,7 @@ const DEFAULTS: Omit<
// No status line items by default: the TUI picks its build-specific default
// when `tui.statusLine` is absent.
tui: {},
telemetry: { enabled: false },
telemetry: { enabled: false, metrics: false, diagnostics: false },
opencode: {
xdg: {
dataIsolation: false,
Expand Down Expand Up @@ -2098,6 +2102,8 @@ function parseTelemetryConfig(raw: unknown): TelemetryConfig {
const enabled = Reflect.get(raw, "enabled");
return {
enabled: typeof enabled === "boolean" ? enabled : DEFAULTS.telemetry.enabled,
metrics: Reflect.get(raw, "metrics") === true,
diagnostics: Reflect.get(raw, "diagnostics") === true,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { isTelemetryChannelEnabled, type TelemetryChannel } from './telemetry-policy.js';
export { isLocalSourceProvenanceEnabled } from './source-provenance.js';
export { writeTuiStatusLineSetting } from './tui-status-line-write.js';
export { parseRunawayGuardOverride, resolveRunawayGuardConfig } from './runaway-guard-config.js';
Expand Down
23 changes: 23 additions & 0 deletions packages/config/src/telemetry-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getConfig, type TelemetryConfig } from './config.js';

export type TelemetryChannel = keyof TelemetryConfig;

const TELEMETRY_OPT_OUT_ENV = ['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'] as const;

/**
* True only when the channel is explicitly opted in and no global opt-out is set.
* An unreadable config never authorizes an upload.
*/
export function isTelemetryChannelEnabled(
channel: TelemetryChannel,
readConfigured: () => boolean | undefined = () => getConfig().telemetry[channel],
): boolean {
for (const key of TELEMETRY_OPT_OUT_ENV) {
if (/^(?:1|true|yes|on)$/iu.test(process.env[key]?.trim() ?? '')) return false;
}
try {
return readConfigured() === true;
} catch {
return false;
}
}
4 changes: 3 additions & 1 deletion packages/local-runtime/src/error-reporting/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* every stage; never expose them to callers or affect LLM requests, retries, or turn results.
*/

import { getRuntimeBuildEnv, getRuntimeRegion } from '@mavis/config';
import { getRuntimeBuildEnv, getRuntimeRegion, isTelemetryChannelEnabled } from '@mavis/config';
import { LLM_ERROR_REASONS } from '@mavis/shared/llm-error-classifier';

import { logger } from '../common/logger.js';
Expand Down Expand Up @@ -62,6 +62,7 @@ export function createDesktopErrorReporter(

/** Read login state, encrypt each event_log, and POST the batch without letting failures affect the main flow. */
async function encryptAndSend(events: DesktopErrorLog[]): Promise<void> {
if (!isTelemetryChannelEnabled('diagnostics', options.readTelemetryEnabled)) return;
const authContext = options.authContextGetter?.();
const token = authContext?.accessToken?.trim();
const userId = authContext?.realUserID?.trim();
Expand Down Expand Up @@ -103,6 +104,7 @@ export function createDesktopErrorReporter(
return {
report(event: DesktopErrorLog): void {
try {
if (!isTelemetryChannelEnabled('diagnostics', options.readTelemetryEnabled)) return;
// Drop oversized raw logs entirely, never truncate (design §2). Measure plaintext size to bound both
// memory usage and the final request body.
const byteLength = Buffer.byteLength(event.event_log, 'utf8');
Expand Down
2 changes: 2 additions & 0 deletions packages/local-runtime/src/error-reporting/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface DesktopErrorReporter {
* DESKTOP_ERROR_REPORTING_DEFAULTS}); tests may override them for deterministic results.
*/
export interface DesktopErrorReporterOptions {
/** Explicit `telemetry.diagnostics` opt-in; environment opt-outs always take precedence. */
readTelemetryEnabled?: () => boolean | undefined;
/** Read live login state (token and real user ID) for each send. */
authContextGetter?: () => LocalRuntimeAuthContext | undefined;
/** Managed-backend routing headers, consistent with other cloud calls. */
Expand Down
20 changes: 16 additions & 4 deletions packages/local-runtime/src/runtime/host-metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getRuntimeBuildEnv, getRuntimeRegion, isManagedRuntime } from '@mavis/config';
import {
getRuntimeBuildEnv,
getRuntimeRegion,
isManagedRuntime,
isTelemetryChannelEnabled,
} from '@mavis/config';
import { createPiTurnHistogramBucketsByName } from '@mavis/agent-core/pi-turn-runner';

import { logger } from '../common/logger.js';
Expand All @@ -12,6 +17,8 @@ import {
import type { LocalRuntimeMode } from './mode.js';

export interface LocalRuntimeHostMetricsOptions {
/** Explicit `telemetry.metrics` opt-in; environment opt-outs always take precedence. */
readonly readTelemetryEnabled?: () => boolean | undefined;
readonly runtimeOwnerKind: string;
readonly runtimeMode?: LocalRuntimeMode;
readonly appVersion?: string;
Expand Down Expand Up @@ -101,7 +108,9 @@ export function buildLocalRuntimeMetricsClient(
options: LocalRuntimeHostMetricsOptions,
): MetricsClient {
const managed = isManagedRuntime();
const reporter = options.metricsReporter ?? buildManagedReporter(managed);
// The built-in cloud transport additionally requires an explicit `telemetry.metrics` opt-in.
const cloudEnabled = managed && isTelemetryChannelEnabled('metrics', options.readTelemetryEnabled);
const reporter = options.metricsReporter ?? buildManagedReporter(cloudEnabled);

const onError = (err: unknown): void => {
logger.warn(
Expand All @@ -110,8 +119,11 @@ export function buildLocalRuntimeMetricsClient(
);
};

if (!managed && !options.metricsReporter) {
logger.info({ reason: 'unmanaged_runtime' }, 'Local runtime metrics reporter disabled');
if (!cloudEnabled && !options.metricsReporter) {
logger.info(
{ reason: managed ? 'telemetry_metrics_opt_in_required' : 'unmanaged_runtime' },
'Local runtime metrics reporter disabled',
);
}

return createLocalRuntimeMetricsClient({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ function receiverDecrypt(event: DesktopErrorLog): string {
);
}

function reporterFixture() {
function reporterFixture(readTelemetryEnabled: () => boolean | undefined = () => true) {
const requests: Array<{ url: string; init: RequestInit }> = [];
const reporter = createDesktopErrorReporter({
readTelemetryEnabled,
authContextGetter: () => ({ accessToken: token, realUserID: userId }),
region: () => 'en',
buildEnv: () => 'prod',
Expand All @@ -41,6 +42,44 @@ function reporterFixture() {
}

describe('automatic error upload privacy boundary', () => {
it.each([undefined, false])('does not upload without an explicit diagnostics opt-in (%s)', async (enabled) => {
const { reporter, requests } = reporterFixture(() => enabled);
reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' });
await reporter.flush();
expect(requests).toEqual([]);
});

it.each(['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('%s overrides the diagnostics opt-in', async (key) => {
vi.stubEnv(key, '1');
try {
const { reporter, requests } = reporterFixture(() => true);
reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' });
await reporter.flush();
expect(requests).toEqual([]);
} finally {
vi.unstubAllEnvs();
}
});

it.each(['config', 'MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('drops buffered diagnostics when %s revokes consent before flushing', async (source) => {
let enabled = true;
const { reporter, requests } = reporterFixture(() => enabled);
try {
reporter.report({ event_type: 'llm_request_failure', event_log: '{}', occurred_at_ms: 1, code_location: 'synthetic' });
if (source === 'config') enabled = false;
else vi.stubEnv(source, '1');
await reporter.flush();
expect(requests).toEqual([]);
enabled = true;
vi.unstubAllEnvs();
await reporter.flush();
expect(requests).toEqual([]);
} finally {
await reporter.close();
vi.unstubAllEnvs();
}
});

it('captures the final HTTP batch and decrypts it with receiver credentials without recovering provider content', async () => {
const { reporter, requests } = reporterFixture();
const headers = new Headers({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildLocalRuntimeMetricsClient } from '../../src/runtime/host-metrics.js';
import type { MetricsClient } from '../../src/common/metrics.js';

const clients: MetricsClient[] = [];
const fetchRequest = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubEnv('__MAVIS_RUNTIME_MANAGED', '1');
vi.stubEnv('MAVIS_BUILD_ENV', 'prod');
vi.stubEnv('MAVIS_REGION', 'en');
vi.stubEnv('MCODE_DISABLE_TELEMETRY', '');
vi.stubEnv('DO_NOT_TRACK', '');
fetchRequest.mockReset().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchRequest);
});
afterEach(async () => {
for (const client of clients.splice(0)) await client.close();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

function fixture(metrics?: boolean) {
const client = buildLocalRuntimeMetricsClient({
runtimeOwnerKind: 'tui',
appVersion: '0.4.12',
readTelemetryEnabled: () => metrics,
});
clients.push(client);
return client;
}

describe('automatic runtime metrics consent', () => {
it.each([undefined, false])('does not send production metrics without an explicit opt-in (%s)', async (enabled) => {
const client = fixture(enabled);
client.counter('started_total', 1);
await client.flush();
expect(fetchRequest).not.toHaveBeenCalled();
});

it('sends metrics only after the metrics opt-in', async () => {
const client = fixture(true);
client.counter('started_total', 1);
await client.flush();
expect(fetchRequest).toHaveBeenCalledOnce();
expect(String(fetchRequest.mock.calls[0]![0])).toBe('https://agent.minimax.io/matrix/api/v1/metrics/batch');
});

it.each(['MCODE_DISABLE_TELEMETRY', 'DO_NOT_TRACK'])('%s overrides the metrics opt-in', async (key) => {
vi.stubEnv(key, '1');
const client = fixture(true);
client.counter('started_total', 1);
await client.flush();
expect(fetchRequest).not.toHaveBeenCalled();
});
});
16 changes: 10 additions & 6 deletions packages/tui/src/cli/telemetry-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ export function runMcodeTelemetryCommand(
): string {
const environment = dependencies.environment ?? process.env;
const config = (dependencies.readConfig ?? getConfig)();
const policy = resolveMcodeBusinessTelemetryPolicy({
configEnabled: config.telemetry.enabled,
environment,
});
const channel = (configEnabled: boolean | undefined) =>
resolveMcodeBusinessTelemetryPolicy({ configEnabled, environment });
const policy = channel(config.telemetry.enabled);
const status = {
enabled: policy.enabled,
configured: policy.configured,
blockedBy: policy.blockedBy ?? null,
configFile: (dependencies.readConfigPath ?? getConfigPath)(),
optInSetting: { telemetry: { enabled: true } },
channels: {
usage: policy,
metrics: channel(config.telemetry.metrics),
diagnostics: channel(config.telemetry.diagnostics),
},
optInSetting: { telemetry: { enabled: true, metrics: true, diagnostics: true } },
optOutEnvironment: ['MCODE_DISABLE_TELEMETRY=1', 'DO_NOT_TRACK=1'],
};
if (action === 'status') return `${JSON.stringify(status, null, 2)}\n`;
Expand All @@ -41,7 +45,7 @@ export function runMcodeTelemetryCommand(
{
...status,
request: null,
message: 'Telemetry is disabled. No business telemetry request will be sent.',
message: 'Usage telemetry is disabled. No business telemetry request will be sent.',
},
null,
2,
Expand Down
Loading
Loading