Files
2026-07-13 12:52:40 +08:00

432 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Telemetry smoke test for ENG-1902.
*
* Runs each activation/workspace funnel event through the SDK telemetry
* helpers using a minimal capturing telemetry service. Prints the captured
* event names and event-specific payloads so a human can verify no raw
* paths leak and that the legacy snake_case schema is preserved. Activation
* and workspace events are routed through normal `capture` so they respect
* the user's telemetry opt-out setting.
*
* Usage:
* bunx --bun tsx packages/core/scripts/telemetry-smoke.ts
* # or
* bun packages/core/scripts/telemetry-smoke.ts
*/
import type { ITelemetryService, TelemetryProperties } from "@cline/shared";
import {
captureConversationTurnEvent,
captureExtensionActivated,
captureTaskCompleted,
captureTaskCreated,
captureTaskRestarted,
captureWorkspaceInitError,
captureWorkspaceInitialized,
captureWorkspacePathResolved,
} from "../src/services/telemetry/core-events";
import {
emitWorkspaceLifecycleTelemetry,
resetWorkspaceTelemetryForTests,
} from "../src/services/workspace/workspace-telemetry";
type RecordedEvent = {
name: string;
properties?: Record<string, unknown>;
required: boolean;
};
/**
* Minimal capturing stub used by the smoke harness. Intentionally **not**
* declared `implements ITelemetryService` because this file is a CLI smoke
* harness — its purpose is to record `capture()` / `captureRequired()`
* payloads for human inspection, not to track every method that lands on
* the production telemetry interface (e.g. `setMetadata`,
* `setCommonProperties`, the `recordCounter` overload). Casting the
* instance via `unknown as ITelemetryService` at the call site lets the
* harness stay tiny while still typechecking under
* `tsconfig.smoke.json`.
*/
class CapturingTelemetry {
readonly events: RecordedEvent[] = [];
capture(event: { event: string; properties?: TelemetryProperties }): void {
this.events.push({
name: event.event,
properties: event.properties as Record<string, unknown> | undefined,
required: false,
});
}
captureRequired(event: string, properties?: TelemetryProperties): void {
this.events.push({
name: event,
properties: properties as Record<string, unknown> | undefined,
required: true,
});
}
setDistinctId(_id?: string): void {}
updateCommonProperties(_props: TelemetryProperties): void {}
identify(_distinctId: string, _properties?: TelemetryProperties): void {}
isEnabled(): boolean {
return true;
}
async dispose(): Promise<void> {}
async flush(): Promise<void> {}
recordCounter(): void {}
recordHistogram(): void {}
recordGauge(): void {}
}
/**
* Cast helper so call sites can pass the harness stub to functions that
* accept the real `ITelemetryService` without each call duplicating the
* `unknown` cast. See {@link CapturingTelemetry} for why we don't track
* the production interface directly.
*/
function asITelemetryService(stub: CapturingTelemetry): ITelemetryService {
return stub as unknown as ITelemetryService;
}
function header(title: string) {
console.log(`\n=== ${title} ===`);
}
function rawPathLeakCheck(events: RecordedEvent[], rawPath: string) {
for (const evt of events) {
const dump = JSON.stringify(evt.properties ?? {});
if (dump.includes(rawPath)) {
console.log(`!! event ${evt.name} leaks raw path; properties=${dump}`);
process.exitCode = 2;
return;
}
}
console.log(
`✓ no raw workspace path "${rawPath}" leaked into ${events.length} captured event(s).`,
);
}
function dumpEvents(events: RecordedEvent[]) {
for (const evt of events) {
// Activation/workspace events are now routed through normal `capture`
// so the [required] tag is only kept for visibility on any unexpected
// captureRequired() usage during the smoke run.
const tag = evt.required ? "[required]" : "[event] ";
console.log(`${tag} ${evt.name} ${JSON.stringify(evt.properties ?? {})}`);
}
}
/**
* Assertion helper for smoke checks. Logs the comparison and, when the
* assertion fails, sets `process.exitCode = 1` so CI can catch regressions
* if the smoke harness is wired into a check step. Without this the script
* would happily exit 0 on a count mismatch and silently mask broken
* telemetry plumbing.
*/
function assertSmoke(
label: string,
actual: number | string | boolean,
expected: number | string | boolean,
): void {
const ok = actual === expected;
const marker = ok ? "✓" : "!!";
console.log(`${marker} ${label}: actual=${actual} expected=${expected}`);
if (!ok) {
process.exitCode = 1;
}
}
async function main() {
resetWorkspaceTelemetryForTests();
header("user.extension_activated (CLI/VS Code activation parity)");
{
const t = new CapturingTelemetry();
captureExtensionActivated(asITelemetryService(t));
dumpEvents(t.events);
}
header("workspace.initialized + workspace.init_error (lifecycle emitter)");
{
const t = new CapturingTelemetry();
const healthyRoot = "/tmp/healthy-repo";
const brokenRoot = "/tmp/broken-repo";
const tSvc = asITelemetryService(t);
emitWorkspaceLifecycleTelemetry({
telemetry: tSvc,
rootPath: healthyRoot,
rootCount: 1,
vcsType: "git",
durationMs: 92.7155,
featureFlagEnabled: true,
});
// duplicate call to verify per-process de-dup
emitWorkspaceLifecycleTelemetry({
telemetry: tSvc,
rootPath: healthyRoot,
rootCount: 1,
vcsType: "git",
durationMs: 12.345,
});
emitWorkspaceLifecycleTelemetry({
telemetry: tSvc,
rootPath: brokenRoot,
rootCount: 0,
vcsType: "none",
initError: {
errorType: "WorkspaceSetupError",
message: "permission denied while probing git repository",
},
});
dumpEvents(t.events);
rawPathLeakCheck(t.events, healthyRoot);
rawPathLeakCheck(t.events, brokenRoot);
const initialized = t.events.filter(
(e) => e.name === "workspace.initialized",
);
const initError = t.events.filter((e) => e.name === "workspace.init_error");
console.log(
`emitted ${initialized.length} workspace.initialized and ${initError.length} workspace.init_error events; expected 2 + 1.`,
);
assertSmoke(
"workspace.initialized count (after de-dup gate)",
initialized.length,
2,
);
assertSmoke("workspace.init_error count", initError.length, 1);
}
header("explicit captureWorkspaceInitialized payload sample");
{
const t = new CapturingTelemetry();
captureWorkspaceInitialized(asITelemetryService(t), {
root_count: 2,
vcs_types: ["git", "hg"],
init_duration_ms: 134.5,
feature_flag_enabled: true,
is_remote_workspace: false,
});
dumpEvents(t.events);
}
header("explicit captureWorkspaceInitError payload sample");
{
const t = new CapturingTelemetry();
captureWorkspaceInitError(
asITelemetryService(t),
Object.assign(new Error("simulated init failure"), {
name: "WorkspaceSetupError",
}),
{ fallback_to_single_root: true, workspace_count: 0 },
);
dumpEvents(t.events);
}
header("explicit captureWorkspacePathResolved payload sample");
{
const t = new CapturingTelemetry();
captureWorkspacePathResolved(asITelemetryService(t), {
ulid: "ulid-smoke-explicit",
context: "search_codebase",
resolution_type: "hint_provided",
hint_type: "workspace_name",
resolution_success: true,
target_workspace_index: 1,
is_multi_root_enabled: true,
});
dumpEvents(t.events);
}
header("task.created (canonical task-start event for new sessions)");
{
const t = new CapturingTelemetry();
captureTaskCreated(asITelemetryService(t), {
ulid: "ulid-smoke-task-1",
apiProvider: "anthropic",
});
dumpEvents(t.events);
const created = t.events.filter((e) => e.name === "task.created");
console.log(
`emitted ${created.length} task.created event(s); expected exactly 1.`,
);
assertSmoke("task.created count", created.length, 1);
}
header("task.restarted (sibling event when an existing task is resumed)");
{
const t = new CapturingTelemetry();
captureTaskRestarted(asITelemetryService(t), {
ulid: "ulid-smoke-task-1",
apiProvider: "anthropic",
});
dumpEvents(t.events);
const restarted = t.events.filter((e) => e.name === "task.restarted");
console.log(
`emitted ${restarted.length} task.restarted event(s); expected exactly 1.`,
);
assertSmoke("task.restarted count", restarted.length, 1);
}
header("task.conversation_turn (user + assistant turns)");
{
const t = new CapturingTelemetry();
const tSvc = asITelemetryService(t);
captureConversationTurnEvent(tSvc, {
ulid: "ulid-smoke-task-1",
provider: "anthropic",
model: "claude-sonnet-4-5",
source: "user",
mode: "act",
});
captureConversationTurnEvent(tSvc, {
ulid: "ulid-smoke-task-1",
provider: "anthropic",
model: "claude-sonnet-4-5",
source: "assistant",
mode: "act",
});
dumpEvents(t.events);
const turns = t.events.filter((e) => e.name === "task.conversation_turn");
const userTurns = turns.filter((e) => e.properties?.source === "user");
const assistantTurns = turns.filter(
(e) => e.properties?.source === "assistant",
);
const allHaveTimestamp = turns.every(
(e) => typeof e.properties?.timestamp === "string",
);
console.log(
`emitted ${turns.length} task.conversation_turn event(s); expected 2 (1 user + 1 assistant).`,
);
console.log(
` user=${userTurns.length} assistant=${assistantTurns.length} all_have_timestamp=${allHaveTimestamp}`,
);
assertSmoke("task.conversation_turn count", turns.length, 2);
assertSmoke("task.conversation_turn user count", userTurns.length, 1);
assertSmoke(
"task.conversation_turn assistant count",
assistantTurns.length,
1,
);
assertSmoke(
"task.conversation_turn timestamps populated",
allHaveTimestamp,
true,
);
}
header(
"task.completed source=submit_and_exit (assistant declared completion)",
);
{
const t = new CapturingTelemetry();
captureTaskCompleted(asITelemetryService(t), {
ulid: "ulid-smoke-task-1",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
mode: "act",
durationMs: 12_345,
source: "submit_and_exit",
});
dumpEvents(t.events);
const completed = t.events.filter((e) => e.name === "task.completed");
const submitSourced = completed.filter(
(e) => e.properties?.source === "submit_and_exit",
);
console.log(
`emitted ${completed.length} task.completed event(s); expected 1 with source="submit_and_exit" (got ${submitSourced.length}).`,
);
assertSmoke("task.completed (submit_and_exit) count", completed.length, 1);
assertSmoke(
"task.completed source=submit_and_exit count",
submitSourced.length,
1,
);
}
header(
"task.completed source=shutdown (non-interactive fallback when submit_and_exit was not observed)",
);
{
const t = new CapturingTelemetry();
captureTaskCompleted(asITelemetryService(t), {
ulid: "ulid-smoke-task-2",
provider: "anthropic",
modelId: "claude-sonnet-4-5",
mode: "act",
durationMs: 9_876,
source: "shutdown",
});
dumpEvents(t.events);
const completed = t.events.filter((e) => e.name === "task.completed");
const shutdownSourced = completed.filter(
(e) => e.properties?.source === "shutdown",
);
console.log(
`emitted ${completed.length} task.completed event(s); expected 1 with source="shutdown" (got ${shutdownSourced.length}).`,
);
assertSmoke("task.completed (shutdown) count", completed.length, 1);
assertSmoke(
"task.completed source=shutdown count",
shutdownSourced.length,
1,
);
}
header(
"task lifecycle in order: created → conversation_turn × 2 → completed (single capturing telemetry)",
);
{
const t = new CapturingTelemetry();
const tSvc = asITelemetryService(t);
const ulid = "ulid-smoke-task-lifecycle";
captureTaskCreated(tSvc, {
ulid,
apiProvider: "anthropic",
});
captureConversationTurnEvent(tSvc, {
ulid,
provider: "anthropic",
model: "claude-sonnet-4-5",
source: "user",
mode: "act",
});
captureConversationTurnEvent(tSvc, {
ulid,
provider: "anthropic",
model: "claude-sonnet-4-5",
source: "assistant",
mode: "act",
});
captureTaskCompleted(tSvc, {
ulid,
provider: "anthropic",
modelId: "claude-sonnet-4-5",
mode: "act",
durationMs: 4_242,
source: "submit_and_exit",
});
dumpEvents(t.events);
const order = t.events.map((e) => e.name);
const expectedOrder = [
"task.created",
"task.conversation_turn",
"task.conversation_turn",
"task.completed",
];
const orderOk =
order.length === expectedOrder.length &&
order.every((name, idx) => name === expectedOrder[idx]);
console.log(
`event order: [${order.join(", ")}]; expected [${expectedOrder.join(", ")}]; ok=${orderOk}.`,
);
assertSmoke("task lifecycle event order", orderOk, true);
}
header("Done");
}
main().catch((err) => {
console.error("smoke test failed:", err);
process.exitCode = 1;
});