chore(telemetry): normalize telemetry property keys to snake_case (#1196)

- Rename camelCase telemetry keys to snake_case on compaction_finished, compaction_failed, micro_compaction_finished, and the tool error event (tokens_before, tokens_after, compacted_count, retry_count, thinking_level, error_type, input_tokens/output_tokens, and the micro compaction config/effect keys).

- Emit a fixed client-attribution key set (client_id/name/version/ui_mode, null when absent) from both session_started producers (core-impl and kimi-harness) so they share a stable schema.

- Drop the duplicate current/latest keys on update_prompted and the redundant ui_mode on server_started.

- Additive fields: login.method=oauth and question_answered.answered.

Telemetry-only change; no changeset.
This commit is contained in:
7Sageer 2026-06-29 17:40:50 +08:00 committed by GitHub
parent 3e98e709b3
commit 10ffb7d9f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 110 additions and 85 deletions

View file

@ -18,7 +18,7 @@ import { startServer, type RunningServer } from '@moonshot-ai/server';
import chalk from 'chalk';
import { Option, type Command } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { darkColors } from '#/tui/theme/colors';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
@ -346,7 +346,7 @@ async function runServerInProcess(
},
});
track('server_started', { ui_mode: WEB_UI_MODE, daemon: mode.daemon });
track('server_started', { daemon: mode.daemon });
process.once('SIGINT', () => {
void shutdown('SIGINT');

View file

@ -430,8 +430,6 @@ function trackUpdatePrompted(
rolloutTelemetry: RolloutTelemetry,
): void {
trackUpdateEvent(track, 'update_prompted', {
current: currentVersion,
latest: target.version,
current_version: currentVersion,
target_version: target.version,
source,

View file

@ -70,6 +70,7 @@ async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
}
host.track('login', {
provider: DEFAULT_OAUTH_PROVIDER_NAME,
method: 'oauth',
already_logged_in: alreadyLoggedIn,
});
if (alreadyLoggedIn) {

View file

@ -828,8 +828,8 @@ describe('runUpdatePreflight', () => {
const track = vi.fn();
await runUpdatePreflight('0.4.0', { ...options, track });
expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({
current: '0.4.0',
latest: '0.5.0',
current_version: '0.4.0',
target_version: '0.5.0',
decision: 'prompt-install',
source: 'npm-global',
}));
@ -915,7 +915,7 @@ describe('runUpdatePreflight', () => {
expect.objectContaining({ target: { version: '0.5.0' } }),
);
expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({
latest: '0.5.0',
target_version: '0.5.0',
rollout_bucket: expect.any(Number),
rollout_delay_seconds: 0,
rollout_from_manifest: true,
@ -945,7 +945,7 @@ describe('runUpdatePreflight', () => {
expect.objectContaining({ target: { version: '0.7.0' } }),
);
expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({
latest: '0.7.0',
target_version: '0.7.0',
rollout_bucket: expect.any(Number),
rollout_delay_seconds: 43_200,
rollout_from_manifest: true,

View file

@ -1269,6 +1269,7 @@ describe('KimiTUI startup', () => {
});
expect(harness.track).toHaveBeenCalledWith('login', {
provider: 'managed:kimi-code',
method: 'oauth',
already_logged_in: false,
});
});
@ -1302,6 +1303,7 @@ describe('KimiTUI startup', () => {
);
expect(harness.track).toHaveBeenCalledWith('login', {
provider: 'managed:kimi-code',
method: 'oauth',
already_logged_in: true,
});
});

View file

@ -1314,7 +1314,7 @@ export class AcpSession {
// event so dashboards stay coherent.
this.emitTelemetry('question_dismissed');
} else {
this.emitTelemetry('question_answered');
this.emitTelemetry('question_answered', { answered: Object.keys(answer).length });
}
return answer;
} catch (err) {

View file

@ -163,7 +163,7 @@ describe('AcpSession.handleQuestion', () => {
expect(req.toolCall.content).toEqual([
{ type: 'content', content: { type: 'text', text: '哪个口味?' } },
]);
expect(trackCalls).toEqual([{ event: 'question_answered', properties: undefined }]);
expect(trackCalls).toEqual([{ event: 'question_answered', properties: { answered: 1 } }]);
});
it('skip: q0_skip resolves to null with question_dismissed telemetry', async () => {
@ -207,7 +207,7 @@ describe('AcpSession.handleQuestion', () => {
// Telemetry: degraded(multi_question) first, then answered.
expect(trackCalls).toEqual([
{ event: 'question_degraded', properties: { reason: 'multi_question', dropped: 2 } },
{ event: 'question_answered', properties: undefined },
{ event: 'question_answered', properties: { answered: 1 } },
]);
// log.warn fired with the dropped count.
expect(warnSpy).toHaveBeenCalledWith(
@ -236,7 +236,7 @@ describe('AcpSession.handleQuestion', () => {
expect(raw.permissionRequests).toHaveLength(1);
expect(trackCalls).toEqual([
{ event: 'question_degraded', properties: { reason: 'multi_select' } },
{ event: 'question_answered', properties: undefined },
{ event: 'question_answered', properties: { answered: 1 } },
]);
});

View file

@ -6,6 +6,7 @@ import {
} from '#/errors';
import {
APIEmptyResponseError,
inputTotal,
isRetryableGenerateError,
type GenerateResult,
type Message,
@ -413,29 +414,35 @@ export class FullCompaction {
tokensAfter,
};
// Telemetry keys are snake_case, but the `context.apply_compaction`
// record written below keeps its persisted camelCase field names
// (consumed by external projectors). The two channels intentionally
// diverge — don't rename the record side to match.
this.agent.telemetry.track('compaction_finished', {
tokensBefore: result.tokensBefore,
tokensAfter: result.tokensAfter,
source: data.source,
tokens_before: result.tokensBefore,
tokens_after: result.tokensAfter,
duration_ms: Date.now() - startedAt,
compactedCount: result.compactedCount,
retryCount,
compacted_count: result.compactedCount,
retry_count: retryCount,
round,
thinkingLevel: this.agent.config.thinkingLevel,
...usage,
...data,
thinking_level: this.agent.config.thinkingLevel,
...(usage === null
? {}
: { input_tokens: inputTotal(usage), output_tokens: usage.output }),
});
this.agent.context.applyCompaction(result);
return result;
} catch (error) {
if (isAbortError(error)) return;
this.agent.telemetry.track('compaction_failed', {
...data,
tokensBefore,
source: data.source,
tokens_before: tokensBefore,
duration_ms: Date.now() - startedAt,
round,
retryCount,
thinkingLevel: this.agent.config.thinkingLevel,
errorType: error instanceof Error ? error.name : 'Unknown',
retry_count: retryCount,
thinking_level: this.agent.config.thinkingLevel,
error_type: error instanceof Error ? error.name : 'Unknown',
});
if (isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED) throw error;
throw new KimiError(ErrorCodes.COMPACTION_FAILED, String(error), { cause: error });

View file

@ -71,7 +71,7 @@ export class MicroCompaction {
const previousEffect = this.measureEffect(history, previousCutoff);
const rawContextTokens = estimateTokensForMessages(history);
// Whole-context length before/after this cutoff change, mirroring the
// `tokensBefore`/`tokensAfter` fields on `compaction_finished` so the
// `tokens_before`/`tokens_after` fields on `compaction_finished` so the
// two compaction paths can be compared on the same axis.
const tokensBefore =
rawContextTokens -
@ -82,15 +82,21 @@ export class MicroCompaction {
effect.truncatedToolResultTokensBefore +
effect.truncatedToolResultTokensAfter;
this.agent.telemetry.track('micro_compaction_finished', {
...config,
...effect,
tokensBefore,
tokensAfter,
keep_recent_messages: config.keepRecentMessages,
min_content_tokens: config.minContentTokens,
cache_missed_threshold_ms: config.cacheMissedThresholdMs,
truncated_marker: config.truncatedMarker,
min_context_usage_ratio: config.minContextUsageRatio,
truncated_tool_result_count: effect.truncatedToolResultCount,
truncated_tool_result_tokens_before: effect.truncatedToolResultTokensBefore,
truncated_tool_result_tokens_after: effect.truncatedToolResultTokensAfter,
tokens_before: tokensBefore,
tokens_after: tokensAfter,
previous_cutoff: previousCutoff,
cutoff: nextCutoff,
message_count: history.length,
cache_age_ms: cacheAgeMs,
thinkingLevel: this.agent.config.thinkingLevel,
thinking_level: this.agent.config.thinkingLevel,
});
}
}

View file

@ -566,7 +566,7 @@ export class ToolManager {
...base,
outcome: 'error',
duration_ms: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
error_type: error instanceof Error ? error.name : 'Unknown',
});
throw error;
}

View file

@ -1141,19 +1141,16 @@ function telemetryErrorReason(error: unknown): string {
function clientTelemetryProperties(client: ClientTelemetryInfo | undefined): TelemetryProperties {
if (client === undefined) return {};
const properties: Record<string, string> = {};
addNonEmpty(properties, 'client_id', client.id);
addNonEmpty(properties, 'client_name', client.name);
addNonEmpty(properties, 'client_version', client.version);
addNonEmpty(properties, 'ui_mode', client.uiMode);
return properties;
}
function addNonEmpty(target: Record<string, string>, key: string, value: string | undefined): void {
const trimmed = value?.trim();
if (trimmed !== undefined && trimmed.length > 0) {
target[key] = trimmed;
}
// Emit a fixed key set (null when the client did not provide a field) so
// `session_started` has a stable schema across clients, matching the harness
// producer in `kimi-harness.ts`. Other session events also inherit these as
// context properties, so they share the same stable client-attribution shape.
return {
client_id: client.id ?? null,
client_name: client.name ?? null,
client_version: client.version ?? null,
ui_mode: client.uiMode ?? null,
};
}
async function resumeSessionResult(

View file

@ -235,17 +235,14 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'manual',
instruction: 'Keep the important test facts.',
tokensBefore: 39,
tokensAfter: 5,
tokens_before: 39,
tokens_after: 5,
duration_ms: expect.any(Number),
compactedCount: 6,
retryCount: 0,
thinkingLevel: 'off',
inputOther: 520,
output: 8,
inputCacheRead: 0,
inputCacheCreation: 0,
compacted_count: 6,
retry_count: 0,
thinking_level: 'off',
input_tokens: 520,
output_tokens: 8,
}),
});
await ctx.expectResumeMatches();
@ -514,8 +511,8 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'manual',
tokensBefore: 25,
retryCount: 1,
tokens_before: 25,
retry_count: 1,
}),
});
await ctx.expectResumeMatches();
@ -650,8 +647,8 @@ describe('FullCompaction', () => {
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
retryCount: 4,
errorType: 'APIEmptyResponseError',
retry_count: 4,
error_type: 'APIEmptyResponseError',
}),
});
// No summary was ever applied; the original history is left intact.
@ -764,16 +761,16 @@ describe('FullCompaction', () => {
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
tokensBefore: 25,
tokens_before: 25,
duration_ms: expect.any(Number),
round: 1,
retryCount: 0,
errorType: 'Error',
retry_count: 0,
error_type: 'Error',
}),
});
expect(
records.find((record) => record.event === 'compaction_failed')?.properties,
).not.toHaveProperty('tokensAfter');
).not.toHaveProperty('tokens_after');
await ctx.expectResumeMatches();
});
@ -878,10 +875,10 @@ describe('FullCompaction', () => {
event: 'compaction_failed',
properties: expect.objectContaining({
source: 'manual',
tokensBefore: 25,
tokens_before: 25,
duration_ms: expect.any(Number),
retryCount: 4,
errorType: 'APIConnectionError',
retry_count: 4,
error_type: 'APIConnectionError',
}),
});
await ctx.expectResumeMatches();
@ -1212,10 +1209,10 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
tokensBefore: 46,
tokensAfter: 28,
compactedCount: 4,
retryCount: 0,
tokens_before: 46,
tokens_after: 28,
compacted_count: 4,
retry_count: 0,
}),
});
await ctx.expectResumeMatches();
@ -1740,7 +1737,7 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
thinkingLevel: 'high',
thinking_level: 'high',
}),
});
});

View file

@ -471,24 +471,27 @@ describe('MicroCompaction', () => {
const event = singleTelemetryEvent(records, 'micro_compaction_finished');
expect(event.properties).toMatchObject({
...microCompaction,
truncatedMarker: DEFAULT_MARKER,
keep_recent_messages: microCompaction.keepRecentMessages,
min_content_tokens: microCompaction.minContentTokens,
cache_missed_threshold_ms: microCompaction.cacheMissedThresholdMs,
truncated_marker: DEFAULT_MARKER,
min_context_usage_ratio: microCompaction.minContextUsageRatio,
previous_cutoff: 0,
cutoff: 7,
message_count: 9,
cache_age_ms: 61 * MINUTE,
truncatedToolResultCount: 2,
truncatedToolResultTokensBefore: expect.any(Number),
truncatedToolResultTokensAfter: expect.any(Number),
tokensBefore: expect.any(Number),
tokensAfter: expect.any(Number),
thinkingLevel: 'off',
truncated_tool_result_count: 2,
truncated_tool_result_tokens_before: expect.any(Number),
truncated_tool_result_tokens_after: expect.any(Number),
tokens_before: expect.any(Number),
tokens_after: expect.any(Number),
thinking_level: 'off',
});
expect(numberProperty(event, 'truncatedToolResultTokensBefore')).toBeGreaterThan(
numberProperty(event, 'truncatedToolResultTokensAfter'),
expect(numberProperty(event, 'truncated_tool_result_tokens_before')).toBeGreaterThan(
numberProperty(event, 'truncated_tool_result_tokens_after'),
);
expect(numberProperty(event, 'tokensBefore')).toBeGreaterThan(
numberProperty(event, 'tokensAfter'),
expect(numberProperty(event, 'tokens_before')).toBeGreaterThan(
numberProperty(event, 'tokens_after'),
);
expect(ctx.agent.context.messages).toHaveLength(9);
@ -533,9 +536,9 @@ describe('MicroCompaction', () => {
expect(secondEvent.properties).toMatchObject({
previous_cutoff: 4,
cutoff: 7,
truncatedToolResultCount: 2,
tokensBefore: expectedContextTokensBefore,
tokensAfter: estimateTokensForMessages(ctx.agent.context.messages),
truncated_tool_result_count: 2,
tokens_before: expectedContextTokensBefore,
tokens_after: estimateTokensForMessages(ctx.agent.context.messages),
});
});

View file

@ -265,6 +265,11 @@ export class KimiHarness {
...sessionScoped,
// Canonical fields are owned by the harness and must win over any
// caller-supplied sessionStartedProperties that happen to share a key.
// `client_id` is always null here: a single-process host has no
// per-connection client id (that concept only exists for daemon clients,
// see core-impl.ts). Kept as an explicit key so both producers share the
// same session_started schema.
client_id: null,
client_name: this.identity?.userAgentProduct ?? null,
client_version: this.identity?.version ?? null,
ui_mode: this.uiMode,

View file

@ -111,6 +111,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -132,6 +133,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -169,6 +171,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'print',
@ -201,6 +204,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -236,6 +240,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -255,6 +260,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -295,6 +301,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -333,6 +340,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: forked.id,
properties: {
client_id: null,
client_name: 'kimi-code-cli',
client_version: '0.0.0-test',
ui_mode: 'shell',
@ -368,6 +376,7 @@ describe('KimiHarness.createSession transport link', () => {
event: 'session_started',
sessionId: session.id,
properties: {
client_id: null,
client_name: null,
client_version: null,
ui_mode: 'shell',