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
63 changes: 63 additions & 0 deletions docs/telemetry-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,69 @@ Leave `conversationLogPath` empty (or omit it) to use the default `<data>/conver
- `redactEmails` (boolean): Redact email addresses (default: `true`)
- `redactPersonalInfo` (boolean): Redact PII patterns (default: `true`)

#### Client Performance Telemetry

LLxprt can optionally collect **local client-side performance telemetry** β€”
timing data for client phases (prepare, stream handling, Ink rendering, stdout
writes, finalization), provider/tool activity intervals, and operation lifecycle
metadata. This data is written to local JSONL files and is **never transmitted
externally**.

Both keys are **disabled by default**. To enable:

```json
{
"telemetry": {
"perf": {
"enabled": true,
"memory": true
}
}
}
```

- `telemetry.perf.enabled` (boolean): Master switch for performance telemetry. Default: `false`. When `false`, no perf files are created, no observers are installed, and no memory ring is allocated.
- `telemetry.perf.memory` (boolean): Include memory trend data (RSS, heap, external, array buffers) in perf records. Default: `false`. **Effective only when `enabled` is `true`** β€” memory is gated by the master switch. When perf is enabled but memory is off, operation records omit the memory columns entirely (absent, not zero-filled).

`telemetry.perf` is an **object**, not a boolean. Setting it to `true` or `false`
directly is invalid.

When perf telemetry is enabled, data is persisted to local JSONL files and is
**never transmitted externally**.

- **Location**: the perf directory is `<global log dir>/perf`, where the global
log dir is `Storage.getGlobalLogDir()` (resolved from `LLXPRT_LOG_HOME`, then
`LLXPRT_CONFIG_HOME`, then the platform default β€” see
[Application Directories](./reference/application-directories.md)). Files are
named `perf-YYYYMMDD-<runUuid>.jsonl` (one per writer per UTC day).
- **What is recorded**: each `operation` record carries identity/build fields
(`session_id`, `operation_id`, `runtime_id`, `project_hash`, `llxprt_version`,
`git_sha`, `runtime`, `platform`), the comparison dimensions (`provider`,
`model`, `render_mode`, terminal geometry), token counts
(`context_tokens`, `output_tokens`), direct client-phase timing
(`client_prepare_ms`, `stream_handler_ms`, `ink_render_ms`,
`stdout_write_sync_ms`, `client_finalize_ms`), provider/tool activity
intervals, the terminal `status`, and `concurrent_instances`. When
`telemetry.perf.memory` is on, `memory_sample` rows additionally carry RSS,
heap, external, and array-buffer bytes with `uptime_ms`. Prompt/response text
is **not** recorded.
- **Retention**: an eventual bound of **64 MiB / 128 artifacts** (JSONL files +
claim files) is enforced oldest-first. A genuinely-live writer β€” today's UTC
day-key with an mtime within the maintenance window β€” is never evicted, and a
non-stale run claim survives while it is active; both still count toward the
caps. This lets a long-running process converge to the bounds by evicting its
own older files while its current file stays safe.
- **Inspection and management** (interactive `/perf` subcommands):
- `/perf` β€” current-process snapshot (live samples, active operation) when
perf is active in this process; otherwise reports it is not active.
- `/perf inspect` β€” directory path, schema version, privacy/default-off
statement, file/record counts, and self-health (skipped/truncated lines,
last write error, evictions).
- `/perf report [--baseline <version|sha>]` β€” grouped p50 metrics by build
and comparison dimensions, with optional matched-dimension delta.
- `/perf delete` β€” removes old/stale perf artifacts (respecting live writers
and active claims).

### Environment Variables

You can also control telemetry through environment variables:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ function createMinimalConfig(options: {
refreshAuth: vi.fn(async () => {}),
setEphemeralSetting: vi.fn(),
getEphemeralSetting: vi.fn(() => undefined),
getTelemetrySettings: () => ({ perf: { enabled: false, memory: false } }),
};
}

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/cli.provider-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ describe('cli main provider initialization', () => {

setTerminalBackground: vi.fn(),
getPolicyEngine: vi.fn(() => null),
getTelemetrySettings: vi.fn(() => ({
perf: { enabled: false, memory: false },
})),
} as unknown as Config;

const { loadCliConfig } = await import('./config/config.js');
Expand Down Expand Up @@ -363,6 +366,9 @@ describe('cli main provider initialization', () => {
getAgentClient,
setTerminalBackground: vi.fn(),
getPolicyEngine: vi.fn(() => null),
getTelemetrySettings: vi.fn(() => ({
perf: { enabled: false, memory: false },
})),
} as unknown as Config;

const resumeResult = makeResumeResult('restored user content');
Expand Down Expand Up @@ -495,6 +501,9 @@ describe('cli main provider initialization', () => {
getAgentClient,
setTerminalBackground: vi.fn(),
getPolicyEngine: vi.fn(() => null),
getTelemetrySettings: vi.fn(() => ({
perf: { enabled: false, memory: false },
})),
} as unknown as Config;

const resumeResult = makeResumeResult('restored user content');
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli.startInteractiveUI.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ describe('startInteractiveUI', () => {
storage: {},
getDebugMode: () => false,
getTerminalBackground: () => undefined,
// Perf disabled: buildAndStartPerfOwner reads getTelemetrySettings() and
// returns null (no perf owner) without touching any other runtime seam.
getTelemetrySettings: () => ({ perf: { enabled: false } }),
} as Config;
const mockAgent = {
dispose: vi.fn().mockResolvedValue(undefined),
Expand Down
26 changes: 14 additions & 12 deletions packages/cli/src/cli.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ void vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({

void vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockResolvedValue({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
getProvider: vi.fn(() => undefined),
getSandbox: () => false,
getQuestion: () => '',
getProvider: () => undefined,
} as unknown as Config),
}));

Expand Down Expand Up @@ -503,10 +503,7 @@ describe('cli.tsx main function', () => {
getIdeClient: vi.fn(() => null),
getListExtensions: vi.fn(() => false),
getOutputFormat: vi.fn(() => OutputFormat.TEXT),
getToolRegistryInfo: vi.fn(() => ({
registered: [],
unregistered: [],
})),
getToolRegistryInfo: vi.fn(() => ({ registered: [], unregistered: [] })),
getSandbox: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getProjectRoot: vi.fn(() => '/tmp/project'),
Expand All @@ -527,6 +524,9 @@ describe('cli.tsx main function', () => {
setTerminalBackground: vi.fn(),
getTerminalBackground: vi.fn(() => undefined),
getPolicyEngine: vi.fn(() => null),
getTelemetrySettings: vi.fn(() => ({
perf: { enabled: false, memory: false },
})),
} as unknown as Config;

const loadSettingsMock = loadSettings as Mock<typeof loadSettings>;
Expand Down Expand Up @@ -590,7 +590,9 @@ describe('cli.tsx main function', () => {
quiet: undefined,
});

const renderMock = vi.fn().mockReturnValue({ unmount: vi.fn() });
const renderMock = vi
.fn()
.mockReturnValue({ clear: vi.fn(), unmount: vi.fn() });
__setRenderForTesting(renderMock);

const originalIsTTY = process.stdin.isTTY;
Expand Down Expand Up @@ -665,10 +667,7 @@ describe('cli.tsx main function', () => {
getIdeClient: vi.fn(() => null),
getListExtensions: vi.fn(() => false),
getOutputFormat: vi.fn(() => OutputFormat.TEXT),
getToolRegistryInfo: vi.fn(() => ({
registered: [],
unregistered: [],
})),
getToolRegistryInfo: vi.fn(() => ({ registered: [], unregistered: [] })),
getSandbox: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getProjectRoot: vi.fn(() => '/tmp/project'),
Expand All @@ -689,6 +688,9 @@ describe('cli.tsx main function', () => {
setTerminalBackground: vi.fn(),
getTerminalBackground: vi.fn(() => undefined),
getPolicyEngine: vi.fn(() => null),
getTelemetrySettings: vi.fn(() => ({
perf: { enabled: false, memory: false },
})),
} as unknown as Config;

const loadSettingsMock = loadSettings as Mock<typeof loadSettings>;
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/configBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function buildTelemetryConfig(argv: CliArgs, settings: Settings) {
enabled: argv.telemetry ?? telemetrySettings?.enabled,
logPrompts: argv.telemetryLogPrompts ?? telemetrySettings?.logPrompts,
outfile: argv.telemetryOutfile ?? telemetrySettings?.outfile,
perf: telemetrySettings?.perf,
...buildTelemetryRedactionConfig(telemetrySettings),
};
}
Expand Down
166 changes: 166 additions & 0 deletions packages/cli/src/config/perfSettingsMerge.behavior.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* @license
* Copyright 2026 Vybestack LLC
* SPDX-License-Identifier: Apache-2.0
*
* Behavioral tests for the real settings merge pipeline as it applies to
* telemetry.perf. Uses production mergeSettings code (no mocks) to prove
* the actual precedence and merge semantics.
*
* EVIDENCE-AC2: persisted settings merge precedence for telemetry.perf.
*/

import { describe, it, expect } from 'bun:test';
import { mergeSettings } from './settingsMerge.js';
import type { Settings } from './settingsSchema.js';

function emptySettings(): Settings {
return {} as Settings;
}

describe('telemetry.perf β€” real mergeSettings behavior', () => {
describe('absent in all layers', () => {
it('produces no perf key in merged telemetry', () => {
const merged = mergeSettings(
emptySettings(),
emptySettings(),
emptySettings(),
emptySettings(),
true,
);
expect(merged.telemetry.perf).toBeUndefined();
});
});

describe('user-only perf', () => {
it('user telemetry.perf.enabled flows through merge', () => {
const user = {
telemetry: { perf: { enabled: true } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
emptySettings(),
user,
emptySettings(),
true,
);
expect(merged.telemetry.perf).toEqual({ enabled: true });
});
});

describe('workspace overrides user (higher precedence)', () => {
it('workspace telemetry.perf replaces user telemetry.perf (shallow merge at telemetry level)', () => {
// The established merge behavior is shallow-spread for the telemetry
// object section. This means a higher-precedence layer's perf object
// replaces the lower-precedence one entirely (documented, not a defect).
const user = {
telemetry: { perf: { enabled: true } },
} as Settings;
const workspace = {
telemetry: { perf: { memory: true } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
emptySettings(),
user,
workspace,
true,
);
// Shallow merge: workspace.perf replaces user.perf entirely
expect(merged.telemetry.perf).toEqual({ memory: true });
});
});

describe('both layers set perf.enabled', () => {
it('workspace perf.enabled wins over user perf.enabled', () => {
const user = {
telemetry: { perf: { enabled: false } },
} as Settings;
const workspace = {
telemetry: { perf: { enabled: true } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
emptySettings(),
user,
workspace,
true,
);
expect(merged.telemetry.perf?.enabled).toBe(true);
});
});

describe('telemetry scalar fields still merge across layers', () => {
it('user telemetry.enabled and workspace telemetry.perf coexist', () => {
const user = {
telemetry: { enabled: true },
} as Settings;
const workspace = {
telemetry: { perf: { enabled: true, memory: true } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
emptySettings(),
user,
workspace,
true,
);
expect(merged.telemetry.enabled).toBe(true);
expect(merged.telemetry.perf).toEqual({ enabled: true, memory: true });
});
});

describe('untrusted workspace is ignored', () => {
it('workspace telemetry.perf is not applied when isTrusted=false', () => {
const workspace = {
telemetry: { perf: { enabled: true } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
emptySettings(),
emptySettings(),
workspace,
false,
);
expect(merged.telemetry.perf).toBeUndefined();
});
});

describe('system layer (highest file precedence)', () => {
it('system telemetry.perf wins over user and workspace', () => {
const system = {
telemetry: { perf: { enabled: true, memory: true } },
} as Settings;
const workspace = {
telemetry: { perf: { enabled: false } },
} as Settings;
const merged = mergeSettings(
system,
emptySettings(),
emptySettings(),
workspace,
true,
);
expect(merged.telemetry.perf).toEqual({ enabled: true, memory: true });
});
});

describe('system defaults layer', () => {
it('systemDefaults telemetry.perf is overridden by user telemetry.perf', () => {
const systemDefaults = {
telemetry: { perf: { enabled: true } },
} as Settings;
const user = {
telemetry: { perf: { enabled: false, memory: false } },
} as Settings;
const merged = mergeSettings(
emptySettings(),
systemDefaults,
user,
emptySettings(),
true,
);
expect(merged.telemetry.perf).toEqual({ enabled: false, memory: false });
});
});
});
Loading
Loading