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
1 change: 1 addition & 0 deletions docs/cli/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Environment variables can override these settings.
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `otlpHeaders` | `GEMINI_TELEMETRY_OTLP_HEADERS` | Custom headers for OTLP exporter requests | JSON/`key=value` | - |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
Expand Down
7 changes: 7 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2550,6 +2550,8 @@ see [Telemetry](../cli/telemetry.md).
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
- **`otlpProtocol`** (string): The protocol for the OTLP Exporter (`grpc` or
`http`).
- **`otlpHeaders`** (object): Custom headers to send with OTLP Exporter
requests.
- **`logPrompts`** (boolean): Whether or not to include the content of user
prompts in the logs.
- **`outfile`** (string): The file to write telemetry to when `target` is
Expand Down Expand Up @@ -2766,6 +2768,11 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- **`GEMINI_TELEMETRY_OTLP_PROTOCOL`**:
- Sets the OTLP protocol (`grpc` or `http`).
- Overrides the `telemetry.otlpProtocol` setting.
- **`GEMINI_TELEMETRY_OTLP_HEADERS`**:
- Sets custom headers for OTLP exporter requests (as a JSON object string or
comma-separated `key=value` pairs; also falls back to
`OTEL_EXPORTER_OTLP_HEADERS`).
- Merges with and overrides headers in the `telemetry.otlpHeaders` setting.
- **`GEMINI_TELEMETRY_LOG_PROMPTS`**:
- Set to `true` or `1` to enable or disable logging of user prompts. Any other
value is treated as disabling it.
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,34 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.telemetry?.enabled).toBe(false);
});

it('should load telemetry.otlpHeaders from settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
const userSettingsContent = {
telemetry: {
enabled: true,
otlpHeaders: {
Authorization: 'Bearer test-token-value',
'x-custom': 'static-val',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.otlpHeaders).toEqual({
Authorization: 'Bearer test-token-value',
'x-custom': 'static-val',
});
});

it('should have telemetry as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
description: 'Protocol for OTLP exporters.',
enum: ['grpc', 'http'],
},
otlpHeaders: {
type: 'object',
description: 'Custom headers to send with OTLP exporter requests.',
additionalProperties: { type: 'string' },
},
traces: {
type: 'boolean',
description:
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,32 @@ describe('Server Config (config.ts)', () => {
const config = new Config(paramsWithoutTelemetry);
expect(config.getTelemetryOtlpProtocol()).toBe('grpc');
});

it('should return provided OTLP headers', () => {
const headers = { Authorization: 'Bearer token123' };
const params: ConfigParameters = {
...baseParams,
telemetry: { enabled: true, otlpHeaders: headers },
};
const config = new Config(params);
expect(config.getTelemetryOtlpHeaders()).toEqual(headers);
});

it('should return empty object for OTLP headers if not provided', () => {
const params: ConfigParameters = {
...baseParams,
telemetry: { enabled: true },
};
const config = new Config(params);
expect(config.getTelemetryOtlpHeaders()).toEqual({});
});

it('should return empty object for OTLP headers if telemetry object is not provided', () => {
const paramsWithoutTelemetry: ConfigParameters = { ...baseParams };
delete paramsWithoutTelemetry.telemetry;
const config = new Config(paramsWithoutTelemetry);
expect(config.getTelemetryOtlpHeaders()).toEqual({});
});
});

describe('UseRipgrep Configuration', () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export interface TelemetrySettings {
target?: TelemetryTarget;
otlpEndpoint?: string;
otlpProtocol?: 'grpc' | 'http';
otlpHeaders?: Record<string, string>;
logPrompts?: boolean;
outfile?: string;
useCollector?: boolean;
Expand Down Expand Up @@ -1096,6 +1097,7 @@ export class Config implements McpContext, AgentLoopContext {
target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET,
otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT,
otlpProtocol: params.telemetry?.otlpProtocol,
otlpHeaders: params.telemetry?.otlpHeaders,
logPrompts: params.telemetry?.logPrompts ?? true,
outfile: params.telemetry?.outfile,
useCollector: params.telemetry?.useCollector,
Expand Down Expand Up @@ -2922,6 +2924,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.telemetrySettings.otlpProtocol ?? 'grpc';
}

getTelemetryOtlpHeaders(): Record<string, string> {
return this.telemetrySettings.otlpHeaders ?? {};
}

getTelemetryTarget(): TelemetryTarget {
return this.telemetrySettings.target ?? DEFAULT_TELEMETRY_TARGET;
}
Expand Down
229 changes: 229 additions & 0 deletions packages/core/src/telemetry/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import {
parseBooleanEnvFlag,
parseOtlpHeaders,
parseTelemetryTargetValue,
resolveTelemetrySettings,
} from './config.js';
Expand Down Expand Up @@ -53,6 +54,122 @@ describe('telemetry/config helpers', () => {
});
});

describe('parseOtlpHeaders', () => {
it('returns undefined for undefined or empty string', () => {
expect(parseOtlpHeaders(undefined)).toBeUndefined();
expect(parseOtlpHeaders('')).toBeUndefined();
expect(parseOtlpHeaders(' ')).toBeUndefined();
});

it('parses JSON object format', () => {
const json = '{"Authorization":"Bearer token123","x-api-key":"abc"}';
expect(parseOtlpHeaders(json)).toEqual({
Authorization: 'Bearer token123',
'x-api-key': 'abc',
});
});

it('returns undefined for empty JSON object', () => {
expect(parseOtlpHeaders('{}')).toBeUndefined();
});

it('returns undefined for malformed JSON or non-string values without falling back to key=value', () => {
expect(parseOtlpHeaders('{Authorization=Bearer token}')).toBeUndefined();
expect(parseOtlpHeaders('{"key": 123}')).toBeUndefined();
expect(parseOtlpHeaders('{"key": null}')).toBeUndefined();
expect(parseOtlpHeaders('{"key": true}')).toBeUndefined();
expect(parseOtlpHeaders('{"valid": "yes", "bad": 42}')).toBeUndefined();
expect(parseOtlpHeaders('["a", "b"]')).toBeUndefined();
});

it('parses comma-separated key=value pairs', () => {
expect(
parseOtlpHeaders('Authorization=Bearer token123,x-api-key=abc'),
).toEqual({
Authorization: 'Bearer token123',
'x-api-key': 'abc',
});
});

it('preserves semicolons inside header values in key=value format', () => {
expect(
parseOtlpHeaders(
'Authorization=Bearer token123;tenant=abc,x-api-key=xyz',
),
).toEqual({
Authorization: 'Bearer token123;tenant=abc',
'x-api-key': 'xyz',
});
});

it('handles values containing equals signs (e.g. base64)', () => {
expect(parseOtlpHeaders('Authorization=Basic dXNlcjpwYXNz==')).toEqual({
Authorization: 'Basic dXNlcjpwYXNz==',
});
});

it('strips surrounding quotes from values in key=value format', () => {
expect(
parseOtlpHeaders('Authorization="Bearer token",x-key=\'val\''),
).toEqual({
Authorization: 'Bearer token',
'x-key': 'val',
});
});

it('trims whitespace around keys and values', () => {
expect(
parseOtlpHeaders(' Authorization = Bearer token , x-key = val '),
).toEqual({
Authorization: 'Bearer token',
'x-key': 'val',
});
});

it('returns undefined for invalid key=value strings', () => {
expect(parseOtlpHeaders('invalid-no-equals')).toBeUndefined();
expect(parseOtlpHeaders('=value_without_key')).toBeUndefined();
});

it('rejects header names with invalid RFC 7230 characters or prototype-polluting keys', () => {
expect(parseOtlpHeaders('bad name=value')).toBeUndefined();
expect(parseOtlpHeaders('{"bad name": "value"}')).toBeUndefined();
expect(parseOtlpHeaders('bad:name=value')).toBeUndefined();
expect(parseOtlpHeaders('__proto__=polluted')).toBeUndefined();
expect(parseOtlpHeaders('constructor=polluted')).toBeUndefined();
expect(parseOtlpHeaders('prototype=polluted')).toBeUndefined();
expect(parseOtlpHeaders('{"__proto__": "polluted"}')).toBeUndefined();
expect(parseOtlpHeaders('{"constructor": "polluted"}')).toBeUndefined();
});

it('rejects header values with control characters (CRLF injection)', () => {
expect(parseOtlpHeaders('x-key=val\r\nInjected: true')).toBeUndefined();
expect(parseOtlpHeaders('x-key=val%0D%0AInjected: true')).toBeUndefined();
expect(parseOtlpHeaders('{"x-key": "val\\u0000bad"}')).toBeUndefined();
});

it('percent-decodes keys and values in key=value format and falls back on malformed encoding', () => {
expect(
parseOtlpHeaders(
'Authorization=Bearer%20token%2C123,x-custom%2Dkey=a%3Db,x-raw=100%unencoded',
),
).toEqual({
Authorization: 'Bearer token,123',
'x-custom-key': 'a=b',
'x-raw': '100%unencoded',
});
});

it('skips invalid pairs in key=value format and keeps valid ones', () => {
expect(
parseOtlpHeaders('Authorization=Bearer token,invalid,=nokey,x-ok=yes'),
).toEqual({
Authorization: 'Bearer token',
'x-ok': 'yes',
});
});
});

describe('resolveTelemetrySettings', () => {
it('falls back to settings when no argv/env provided', async () => {
const settings = {
Expand Down Expand Up @@ -179,5 +296,117 @@ describe('telemetry/config helpers', () => {
/Invalid telemetry target/i,
);
});

it('resolves otlpHeaders from settings', async () => {
const settings = {
otlpHeaders: { Authorization: 'Bearer settings-token' },
};
const resolved = await resolveTelemetrySettings({ settings });
expect(resolved.otlpHeaders).toEqual({
Authorization: 'Bearer settings-token',
});
});

it('resolves otlpHeaders from GEMINI_TELEMETRY_OTLP_HEADERS and OTEL_EXPORTER_OTLP_HEADERS', async () => {
const resolvedOtel = await resolveTelemetrySettings({
env: { OTEL_EXPORTER_OTLP_HEADERS: 'x-otel=1,Authorization=Bearer a' },
});
expect(resolvedOtel.otlpHeaders).toEqual({
'x-otel': '1',
Authorization: 'Bearer a',
});

const resolvedGemini = await resolveTelemetrySettings({
env: {
OTEL_EXPORTER_OTLP_HEADERS: 'x-otel=1,Authorization=Bearer a',
GEMINI_TELEMETRY_OTLP_HEADERS: '{"Authorization":"Bearer b"}',
},
});
expect(resolvedGemini.otlpHeaders).toEqual({
'x-otel': '1',
Authorization: 'Bearer b',
});
});

it('merges otlpHeaders across settings, env, and argv with case-insensitive deduplication', async () => {
const settings = {
otlpHeaders: {
Authorization: 'Bearer settings',
'X-Settings': 'from-settings',
},
};
const env = {
GEMINI_TELEMETRY_OTLP_HEADERS:
'authorization=Bearer env,X-Env=from-env',
};
const argv = {
telemetryOtlpHeaders: 'X-Argv=from-argv',
};
const resolved = await resolveTelemetrySettings({ argv, env, settings });
expect(resolved.otlpHeaders).toEqual({
'X-Settings': 'from-settings',
authorization: 'Bearer env',
'X-Env': 'from-env',
'X-Argv': 'from-argv',
});
});

it('throws FatalConfigError on invalid otlpHeaders without leaking raw secret values', async () => {
await expect(
resolveTelemetrySettings({
env: {
GEMINI_TELEMETRY_OTLP_HEADERS: '{"Authorization":"secret-token-1"',
},
}),
).rejects.toThrow(
'Invalid telemetry OTLP headers in GEMINI_TELEMETRY_OTLP_HEADERS. Expected JSON object or key=value pairs',
);

await expect(
resolveTelemetrySettings({
env: {
OTEL_EXPORTER_OTLP_HEADERS: '{"Authorization":"secret-token-2"',
},
}),
).rejects.toThrow(
'Invalid telemetry OTLP headers in OTEL_EXPORTER_OTLP_HEADERS. Expected JSON object or key=value pairs',
);

await expect(
resolveTelemetrySettings({
argv: { telemetryOtlpHeaders: '{"Authorization":"secret-token-3"' },
}),
).rejects.toThrow(
'Invalid telemetry OTLP headers in argv. Expected JSON object or key=value pairs',
);

await expect(
resolveTelemetrySettings({
settings: { otlpHeaders: { 'bad name': 'value' } },
}),
).rejects.toThrow(/Invalid telemetry OTLP headers in settings/i);

await expect(
resolveTelemetrySettings({
settings: {
otlpHeaders: null as unknown as Record<string, string>,
},
}),
).rejects.toThrow(/Invalid telemetry OTLP headers in settings/i);

await expect(
resolveTelemetrySettings({
argv: { telemetryOtlpHeaders: { 'x-key': 'bad\r\nvalue' } },
}),
).rejects.toThrow(/Invalid telemetry OTLP headers in argv/i);

await expect(
resolveTelemetrySettings({
argv: {
telemetryOtlpHeaders: ['a=b'] as unknown as Record<string, string>,
},
}),
).rejects.toThrow(/Invalid telemetry OTLP headers in argv/i);
});
});
});
Loading
Loading