Inject device_id into tracking events (#12708)

* Inject  into tracking events

* fix sandbox resolution
This commit is contained in:
Tomás Barreiro
2026-07-30 16:13:17 -07:00
committed by GitHub
parent b47851791c
commit 49c7a89882
9 changed files with 104 additions and 5 deletions
@@ -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,
+1 -1
View File
@@ -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,