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
60 changes: 51 additions & 9 deletions docs/developers/development/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,36 @@ observability framework — Qwen Code's observability system provides:
All telemetry behavior is controlled through your `.qwen/settings.json` file.
These settings can be overridden by environment variables or CLI flags.

| Setting | Environment Variable | CLI Flag | Description | Values | Default |
| -------------- | ------------------------------ | -------------------------------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| Setting | Environment Variable | CLI Flag | Description | Values | Default |
| --------------------- | -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `QWEN_TELEMETRY_ENABLED` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `QWEN_TELEMETRY_TARGET` | `--telemetry-target <local\|gcp>` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `QWEN_TELEMETRY_OTLP_ENDPOINT` | `--telemetry-otlp-endpoint <URL>` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `QWEN_TELEMETRY_OTLP_PROTOCOL` | `--telemetry-otlp-protocol <grpc\|http>` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `otlpTracesEndpoint` | `QWEN_TELEMETRY_OTLP_TRACES_ENDPOINT` | - | Per-signal endpoint override for traces (HTTP only) | URL string | - |
| `otlpLogsEndpoint` | `QWEN_TELEMETRY_OTLP_LOGS_ENDPOINT` | - | Per-signal endpoint override for logs (HTTP only) | URL string | - |
| `otlpMetricsEndpoint` | `QWEN_TELEMETRY_OTLP_METRICS_ENDPOINT` | - | Per-signal endpoint override for metrics (HTTP only) | URL string | - |
| `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile <path>` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `QWEN_TELEMETRY_USE_COLLECTOR` | - | Use external OTLP collector (advanced) | `true`/`false` | `false` |

**Note on boolean environment variables:** For the boolean settings (`enabled`,
`logPrompts`, `useCollector`), setting the corresponding environment variable to
`true` or `1` will enable the feature. Any other value will disable it.

**HTTP OTLP signal routing:** When using HTTP protocol (`otlpProtocol: "http"`),
Qwen Code automatically appends signal-specific paths (`/v1/traces`, `/v1/logs`,
`/v1/metrics`) to the base `otlpEndpoint`. For example, `http://collector:4318`
becomes `http://collector:4318/v1/traces` for traces. If the URL already ends
with a signal path, it is used as-is. Per-signal endpoint overrides
(`otlpTracesEndpoint`, etc.) take precedence over the base endpoint and are used
verbatim. gRPC protocol uses service-based routing and does not append paths.

The per-signal endpoint environment variables also accept the standard
OpenTelemetry names: `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`.
The `QWEN_TELEMETRY_OTLP_*` variants take precedence over the `OTEL_*` variants.

For detailed information about all configuration options, see the
[Configuration Guide](./cli/configuration.md).

Expand All @@ -91,6 +107,9 @@ sent to Alibaba Cloud.

1. Enable telemetry in your `.qwen/settings.json` and set the OTLP
endpoint:

**Option A: gRPC protocol** (standard OTLP endpoint):

```json
{
"telemetry": {
Expand All @@ -101,6 +120,29 @@ sent to Alibaba Cloud.
}
}
```

**Option B: HTTP protocol with per-signal endpoints** (for backends
that use non-standard paths, e.g., `/api/otlp/traces` instead of
`/v1/traces`):

```json
{
"telemetry": {
"enabled": true,
"otlpProtocol": "http",
"otlpTracesEndpoint": "http://<host>/<token>/api/otlp/traces",
"otlpLogsEndpoint": "http://<host>/<token>/api/otlp/logs",
"otlpMetricsEndpoint": "http://<host>/<token>/api/otlp/metrics"
}
}
```

> **Note:** When using HTTP protocol with only `otlpEndpoint` (no
> per-signal overrides), Qwen Code appends standard OTLP paths
> (`/v1/traces`, `/v1/logs`, `/v1/metrics`) to the base URL. If your
> backend uses different paths, use per-signal endpoint overrides as
> shown in Option B.

2. If your Alibaba Cloud endpoint requires authentication, provide OTLP
headers through standard OpenTelemetry environment variables such as
`OTEL_EXPORTER_OTLP_HEADERS` (or the signal-specific variants). Qwen
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,11 +493,19 @@ describe('gemini.tsx main function kitty protocol', () => {
let setRawModeSpy: MockInstance<
(mode: boolean) => NodeJS.ReadStream & { fd: 0 }
>;
let initialSigintListeners: NodeJS.SignalsListener[];
let initialSigtermListeners: NodeJS.SignalsListener[];

beforeEach(() => {
// Set no relaunch in tests since process spawning causing issues in tests
originalEnvNoRelaunch = process.env['QWEN_CODE_NO_RELAUNCH'];
process.env['QWEN_CODE_NO_RELAUNCH'] = 'true';
initialSigintListeners = process.listeners(
'SIGINT',
) as NodeJS.SignalsListener[];
initialSigtermListeners = process.listeners(
'SIGTERM',
) as NodeJS.SignalsListener[];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(process.stdin as any).setRawMode) {
Expand All @@ -517,12 +525,24 @@ describe('gemini.tsx main function kitty protocol', () => {
});

afterEach(() => {
for (const listener of process.listeners('SIGINT')) {
if (!initialSigintListeners.includes(listener)) {
process.removeListener('SIGINT', listener as NodeJS.SignalsListener);
}
}
for (const listener of process.listeners('SIGTERM')) {
if (!initialSigtermListeners.includes(listener)) {
process.removeListener('SIGTERM', listener as NodeJS.SignalsListener);
}
}

// Restore original env variables
if (originalEnvNoRelaunch !== undefined) {
process.env['QWEN_CODE_NO_RELAUNCH'] = originalEnvNoRelaunch;
} else {
delete process.env['QWEN_CODE_NO_RELAUNCH'];
}
vi.restoreAllMocks();
});

it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
Expand Down Expand Up @@ -618,6 +638,79 @@ describe('gemini.tsx main function kitty protocol', () => {
expect(setRawModeSpy).toHaveBeenCalledWith(true);
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
});

it('should run cleanup before exiting on interactive SIGINT', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
const processOnceSpy = vi.spyOn(process, 'once').mockImplementation(((
eventName: string | symbol,
listener: (...args: unknown[]) => void,
) => {
if (eventName === 'SIGTERM' || eventName === 'SIGINT') {
signalHandlers.set(eventName, listener);
}
return process;
}) as typeof process.once);
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);

vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
initialize: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getWarnings: () => [],
getModelsConfig: () => ({
getCurrentAuthType: () => null,
getGenerationConfig: () => ({}),
}),
getProxy: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
} as unknown as Config);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
} as never);
vi.mocked(parseArguments).mockResolvedValue({
extensions: undefined,
} as never);

await main();
signalHandlers.get('SIGINT')?.();
await Promise.resolve();
await Promise.resolve();

expect(setRawModeSpy).toHaveBeenCalledWith(false);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(130);

processOnceSpy.mockRestore();
processExitSpy.mockRestore();
});
});

describe('validateDnsResolutionOrder', () => {
Expand Down
53 changes: 45 additions & 8 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,48 @@ ${reason.stack}`
});
}

function getSignalExitCode(signal: NodeJS.Signals): number {
return signal === 'SIGINT' ? 130 : 143;
}

function installInteractiveSignalHandlers(wasRaw: boolean): () => void {
let cleanupStarted = false;

const handleSignal = (signal: NodeJS.Signals) => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(wasRaw);
}

if (cleanupStarted) {
return;
}
cleanupStarted = true;

void runExitCleanup()
.catch((error) => {
debugLogger.error(`Error during ${signal} cleanup:`, error);
})
.finally(() => {
process.exit(getSignalExitCode(signal));
});
};

const handleSigterm = () => {
handleSignal('SIGTERM');
};
const handleSigint = () => {
handleSignal('SIGINT');
};

process.once('SIGTERM', handleSigterm);
process.once('SIGINT', handleSigint);

return () => {
process.removeListener('SIGTERM', handleSigterm);
process.removeListener('SIGINT', handleSigint);
};
}

export async function startInteractiveUI(
config: Config,
settings: LoadedSettings,
Expand Down Expand Up @@ -559,6 +601,9 @@ export async function main() {
const wasRaw = process.stdin.isRaw;
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
let themeAutoDetectionComplete: Promise<void> | undefined;
if (config.isInteractive()) {
registerCleanup(installInteractiveSignalHandlers(wasRaw));
}
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
// Set this as early as possible to avoid spurious characters from
// input showing up in the output.
Expand All @@ -569,14 +614,6 @@ export async function main() {
// Ensure the stdin listener is removed on any exit path (error, signal, etc.)
registerCleanup(() => stopAndGetCapturedInput());

// This cleanup isn't strictly needed but may help in certain situations.
process.on('SIGTERM', () => {
process.stdin.setRawMode(wasRaw);
});
process.on('SIGINT', () => {
process.stdin.setRawMode(wasRaw);
});

// Detect and enable Kitty keyboard protocol once at startup.
kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();

Expand Down
66 changes: 66 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
DEFAULT_TELEMETRY_TARGET,
DEFAULT_OTLP_ENDPOINT,
QwenLogger,
isTelemetrySdkInitialized,
shutdownTelemetry,
} from '../telemetry/index.js';
import type {
ContentGenerator,
Expand Down Expand Up @@ -178,6 +180,8 @@ vi.mock('../telemetry/index.js', async (importOriginal) => {
return {
...actual,
initializeTelemetry: vi.fn(),
isTelemetrySdkInitialized: vi.fn(() => false),
shutdownTelemetry: vi.fn().mockResolvedValue(undefined),
uiTelemetryService: {
getLastPromptTokenCount: vi.fn(),
},
Expand Down Expand Up @@ -276,6 +280,7 @@ describe('Server Config (config.ts)', () => {
beforeEach(() => {
// Reset mocks if necessary
vi.clearAllMocks();
vi.mocked(isTelemetrySdkInitialized).mockReturnValue(false);
vi.spyOn(QwenLogger.prototype, 'logStartSessionEvent').mockImplementation(
async () => undefined,
);
Expand Down Expand Up @@ -848,6 +853,32 @@ describe('Server Config (config.ts)', () => {
expect(config.getTelemetryEnabled()).toBe(true);
});

it('Config shutdown should flush telemetry when SDK is initialized', async () => {
const paramsWithTelemetry: ConfigParameters = {
...baseParams,
telemetry: { enabled: true },
};
vi.mocked(isTelemetrySdkInitialized).mockReturnValue(true);
const config = new Config(paramsWithTelemetry);

await config.shutdown();

expect(shutdownTelemetry).toHaveBeenCalledTimes(1);
});

it('Config shutdown should skip telemetry shutdown before SDK initialization', async () => {
const paramsWithTelemetry: ConfigParameters = {
...baseParams,
telemetry: { enabled: true },
};
vi.mocked(isTelemetrySdkInitialized).mockReturnValue(false);
const config = new Config(paramsWithTelemetry);

await config.shutdown();

expect(shutdownTelemetry).not.toHaveBeenCalled();
});

it('Config constructor should set telemetry to false when provided as false', () => {
const paramsWithTelemetry: ConfigParameters = {
...baseParams,
Expand Down Expand Up @@ -1023,6 +1054,41 @@ describe('Server Config (config.ts)', () => {
});
});

describe('Per-Signal OTLP Endpoint Configuration', () => {
it('should return per-signal endpoints when provided', () => {
const params: ConfigParameters = {
...baseParams,
telemetry: {
enabled: true,
otlpTracesEndpoint: 'http://traces:4318/v1/traces',
otlpLogsEndpoint: 'http://logs:4318/v1/logs',
otlpMetricsEndpoint: 'http://metrics:4318/v1/metrics',
},
};
const config = new Config(params);
expect(config.getTelemetryOtlpTracesEndpoint()).toBe(
'http://traces:4318/v1/traces',
);
expect(config.getTelemetryOtlpLogsEndpoint()).toBe(
'http://logs:4318/v1/logs',
);
expect(config.getTelemetryOtlpMetricsEndpoint()).toBe(
'http://metrics:4318/v1/metrics',
);
});

it('should return undefined when per-signal endpoints are not provided', () => {
const params: ConfigParameters = {
...baseParams,
telemetry: { enabled: true },
};
const config = new Config(params);
expect(config.getTelemetryOtlpTracesEndpoint()).toBeUndefined();
expect(config.getTelemetryOtlpLogsEndpoint()).toBeUndefined();
expect(config.getTelemetryOtlpMetricsEndpoint()).toBeUndefined();
});
});

describe('UseRipgrep Configuration', () => {
it('should default useRipgrep to true when not provided', () => {
const config = new Config(baseParams);
Expand Down
Loading
Loading