mirror of
https://github.com/cline/cline.git
synced 2026-08-29 03:52:41 +08:00
Inject device_id into tracking events (#12708)
* Inject into tracking events * fix sandbox resolution
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { getDeviceId, getDistinctId } from "@/services/logging/distinctId"
|
||||
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Setting } from "@/shared/proto/index.host"
|
||||
@@ -74,6 +74,7 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
name: error.name,
|
||||
extension_version: pkg.version,
|
||||
is_dev: isDev,
|
||||
...this.deviceIdProperty,
|
||||
...properties,
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
name: error.name,
|
||||
extension_version: pkg.version,
|
||||
is_dev: isDev,
|
||||
...this.deviceIdProperty,
|
||||
...properties,
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
extension_version: pkg.version,
|
||||
is_dev: isDev,
|
||||
timestamp: new Date().toISOString(),
|
||||
...this.deviceIdProperty,
|
||||
...properties,
|
||||
},
|
||||
})
|
||||
@@ -155,6 +158,11 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
return getDistinctId()
|
||||
}
|
||||
|
||||
private get deviceIdProperty(): Record<string, string> {
|
||||
const deviceId = getDeviceId()
|
||||
return deviceId ? { device_id: deviceId } : {}
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
// Only shut down the client if it's not shared (we own it)
|
||||
if (!this.isSharedClient) {
|
||||
|
||||
@@ -10,7 +10,13 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
const machineIdStub: sinon.SinonStub = sinon.stub()
|
||||
mock.module("node-machine-id", () => ({ ...actualNodeMachineId, machineId: machineIdStub }))
|
||||
|
||||
import { _GENERATED_MACHINE_ID_KEY, getDistinctId, initializeDistinctId, setDistinctId } from "@/services/logging/distinctId"
|
||||
import {
|
||||
_GENERATED_MACHINE_ID_KEY,
|
||||
getDeviceId,
|
||||
getDistinctId,
|
||||
initializeDistinctId,
|
||||
setDistinctId,
|
||||
} from "@/services/logging/distinctId"
|
||||
import { StorageContext } from "@/shared/storage"
|
||||
|
||||
describe("distinctId", () => {
|
||||
@@ -117,6 +123,19 @@ describe("distinctId", () => {
|
||||
expect(mockGlobalState.update.notCalled).to.be.true
|
||||
})
|
||||
|
||||
it("device ID should match the initial distinct ID and survive auth overrides", async () => {
|
||||
machineIdStub.resolves(MOCK_MACHINE_ID)
|
||||
|
||||
await initializeDistinctId(mockStorage, mockUuidGenerator)
|
||||
expect(getDeviceId()).to.equal(MOCK_MACHINE_ID)
|
||||
|
||||
// Simulate authentication replacing the distinct ID with the user ID.
|
||||
setDistinctId("cline-user-id-789")
|
||||
|
||||
expect(getDistinctId()).to.equal("cline-user-id-789")
|
||||
expect(getDeviceId()).to.equal(MOCK_MACHINE_ID)
|
||||
})
|
||||
|
||||
it("should generate and store UUID if node-machine-id returns empty string", async () => {
|
||||
mockGlobalState.get.withArgs(_GENERATED_MACHINE_ID_KEY).returns(undefined)
|
||||
// Mock node-machine-id to return empty string
|
||||
|
||||
@@ -9,6 +9,13 @@ import { StorageContext } from "@/shared/storage"
|
||||
*/
|
||||
let _distinctId = ""
|
||||
|
||||
/*
|
||||
* Deterministic identifier for the device, resolved once at initialization.
|
||||
* Unlike the distinct ID, this is never replaced by the Cline User ID on auth,
|
||||
* so every session on this device reports the same value.
|
||||
*/
|
||||
let _deviceId = ""
|
||||
|
||||
/**
|
||||
* Some environments don't return a value for the machine ID. For these situations we generated
|
||||
* a unique ID and store it locally.
|
||||
@@ -31,6 +38,7 @@ export async function initializeDistinctId(storage: StorageContext, uuid: () =>
|
||||
storage.globalState.update(_GENERATED_MACHINE_ID_KEY, distinctId)
|
||||
}
|
||||
|
||||
_deviceId = distinctId
|
||||
setDistinctId(distinctId)
|
||||
|
||||
await HostRegistryInfo.init(distinctId)
|
||||
@@ -76,3 +84,13 @@ export function getDistinctId() {
|
||||
}
|
||||
return _distinctId
|
||||
}
|
||||
|
||||
/*
|
||||
* Deterministic identifier for the device (machine ID, or the stored generated
|
||||
* ID as a fallback). Attached to all telemetry events as `device_id` so events
|
||||
* can be tied to a device regardless of auth status. Empty string until
|
||||
* initializeDistinctId() has run.
|
||||
*/
|
||||
export function getDeviceId() {
|
||||
return _deviceId
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Setting } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { Mode } from "@/shared/storage/types"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
import { setDistinctId } from "../logging/distinctId"
|
||||
import { getDeviceId, setDistinctId } from "../logging/distinctId"
|
||||
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
|
||||
import {
|
||||
getRolloutErrorProperties,
|
||||
@@ -508,6 +508,7 @@ export class TelemetryService {
|
||||
const propertiesWithMetadata: TelemetryProperties = {
|
||||
...(event.properties || {}),
|
||||
...this.telemetryMetadata,
|
||||
...this.getDeviceIdProperty(),
|
||||
}
|
||||
this.captureToProviders(event.event, propertiesWithMetadata, false)
|
||||
}
|
||||
@@ -521,6 +522,7 @@ export class TelemetryService {
|
||||
const propertiesWithMetadata: TelemetryProperties = {
|
||||
...(properties || {}),
|
||||
...this.telemetryMetadata,
|
||||
...this.getDeviceIdProperty(),
|
||||
}
|
||||
this.captureToProviders(event, propertiesWithMetadata, true)
|
||||
}
|
||||
@@ -548,12 +550,18 @@ export class TelemetryService {
|
||||
private getStandardAttributes(extra?: TelemetryProperties): TelemetryProperties {
|
||||
return {
|
||||
...this.telemetryMetadata,
|
||||
...this.getDeviceIdProperty(),
|
||||
...(this.userId ? { userId: this.userId } : {}),
|
||||
...this.activeOrg,
|
||||
...(extra ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
private getDeviceIdProperty(): TelemetryProperties {
|
||||
const deviceId = getDeviceId()
|
||||
return deviceId ? { device_id: deviceId } : {}
|
||||
}
|
||||
|
||||
private recordCounter(
|
||||
name: string,
|
||||
value: number,
|
||||
|
||||
@@ -652,7 +652,7 @@ export {
|
||||
SqliteTeamStore,
|
||||
type SqliteTeamStoreOptions,
|
||||
} from "./services/storage/team-store";
|
||||
export { resolveCoreDistinctId } from "./services/telemetry";
|
||||
export { resolveCoreDeviceId, resolveCoreDistinctId } from "./services/telemetry";
|
||||
export type {
|
||||
CaptureAgentUnexpectedReasoningTokensInput,
|
||||
CaptureCompactionExecutedProperties,
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("TelemetryService", () => {
|
||||
cline_type: "cli",
|
||||
},
|
||||
distinctId: "distinct-1",
|
||||
deviceId: "device-1",
|
||||
commonProperties: {
|
||||
organization_id: "org-1",
|
||||
},
|
||||
@@ -36,6 +37,7 @@ describe("TelemetryService", () => {
|
||||
extension_version: "1.2.3",
|
||||
cline_type: "cli",
|
||||
distinct_id: "distinct-1",
|
||||
device_id: "device-1",
|
||||
}),
|
||||
);
|
||||
expect(recordCounter).toHaveBeenCalledWith(
|
||||
@@ -44,12 +46,33 @@ describe("TelemetryService", () => {
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
distinct_id: "distinct-1",
|
||||
device_id: "device-1",
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps device_id on events after the distinct id changes on auth", () => {
|
||||
const { adapter, emit } = createAdapter();
|
||||
const service = new TelemetryService({
|
||||
adapters: [adapter],
|
||||
distinctId: "machine-1",
|
||||
deviceId: "device-1",
|
||||
});
|
||||
|
||||
service.setDistinctId("user-1");
|
||||
service.capture({ event: "session.started" });
|
||||
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
"session.started",
|
||||
expect.objectContaining({
|
||||
distinct_id: "user-1",
|
||||
device_id: "device-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("mirrors telemetry events into the logger when provided", () => {
|
||||
const logger: BasicLogger = {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TelemetryMetadata,
|
||||
TelemetryProperties,
|
||||
} from "@cline/shared";
|
||||
import { resolveCoreDeviceId } from "./distinct-id";
|
||||
import type { ITelemetryAdapter } from "./ITelemetryAdapter";
|
||||
import { TelemetryLoggerSink } from "./TelemetryLoggerSink";
|
||||
|
||||
@@ -11,6 +12,11 @@ export interface TelemetryServiceOptions {
|
||||
adapters?: ITelemetryAdapter[];
|
||||
metadata?: Partial<TelemetryMetadata>;
|
||||
distinctId?: string;
|
||||
/**
|
||||
* Deterministic per-device identifier attached to every event as
|
||||
* `device_id`. Defaults to {@link resolveCoreDeviceId}
|
||||
*/
|
||||
deviceId?: string;
|
||||
commonProperties?: TelemetryProperties;
|
||||
logger?: BasicLogger;
|
||||
}
|
||||
@@ -19,6 +25,7 @@ export class TelemetryService implements ITelemetryService {
|
||||
private adapters: ITelemetryAdapter[];
|
||||
private metadata: Partial<TelemetryMetadata>;
|
||||
private distinctId?: string;
|
||||
private readonly deviceId: string;
|
||||
private commonProperties: TelemetryProperties;
|
||||
|
||||
constructor(options: TelemetryServiceOptions = {}) {
|
||||
@@ -28,6 +35,7 @@ export class TelemetryService implements ITelemetryService {
|
||||
}
|
||||
this.metadata = { ...(options.metadata ?? {}) };
|
||||
this.distinctId = options.distinctId;
|
||||
this.deviceId = options.deviceId ?? resolveCoreDeviceId();
|
||||
this.commonProperties = { ...(options.commonProperties ?? {}) };
|
||||
}
|
||||
|
||||
@@ -134,6 +142,7 @@ export class TelemetryService implements ITelemetryService {
|
||||
...properties,
|
||||
...this.metadata,
|
||||
...(this.distinctId ? { distinct_id: this.distinctId } : {}),
|
||||
device_id: this.deviceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ import * as nodeMachineId from "node-machine-id";
|
||||
|
||||
const GENERATED_DISTINCT_ID_FILE_NAME = "machine-id";
|
||||
|
||||
let cachedMachineId: string | undefined;
|
||||
let machineIdProbed = false;
|
||||
|
||||
export function resolveCoreDeviceId(): string {
|
||||
// Only the machine id is cached: it is environment-independent and probing
|
||||
// it shells out to the OS. The generated fallback is re-resolved on every
|
||||
// call because it lives under the data dir, which can change at runtime
|
||||
if (!machineIdProbed) {
|
||||
cachedMachineId = getMachineDistinctId();
|
||||
machineIdProbed = true;
|
||||
}
|
||||
return cachedMachineId ?? resolveGeneratedFallbackDistinctId();
|
||||
}
|
||||
|
||||
export function resolveCoreDistinctId(explicitDistinctId?: string): string {
|
||||
const normalizedDistinctId = explicitDistinctId?.trim();
|
||||
if (normalizedDistinctId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { resolveCoreDistinctId } from "./distinct-id";
|
||||
export { resolveCoreDeviceId, resolveCoreDistinctId } from "./distinct-id";
|
||||
export type {
|
||||
ITelemetryAdapter,
|
||||
TelemetryArray,
|
||||
|
||||
Reference in New Issue
Block a user