Skip to content

Commit 4c45ea5

Browse files
authored
chore: fix PostHog analytics identity and desktop metadata (#960)
* chore: report desktop app metadata in web analytics * chore: fix stale test expectations and watcher flakiness * chore: unify PostHog user identity tracking * chore: preserve analytics dedupe state before login * chore: harden analytics identity retries and coverage * chore: scope first conversation analytics per user * chore: avoid backfilling anonymous first-conversation analytics * chore: defer first-session dedupe until analytics identity exists * chore: log analytics identity failures and drop dead search helper * chore: handle legacy analytics state and robust workspace watcher paths
1 parent 157b50c commit 4c45ea5

15 files changed

Lines changed: 1233 additions & 143 deletions

apps/controller/src/services/analytics-service.ts

Lines changed: 105 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type InternalSkillSource = "curated" | "managed" | "custom";
1919

2020
type AnalyticsState = {
2121
sessionStartSent: boolean;
22+
firstConversationDistinctId: string | null;
2223
sentUserMessageIds: string[];
2324
sentSkillUseIds: string[];
2425
};
@@ -65,10 +66,16 @@ type ResolvedSkillInfo = {
6566
source: string | null;
6667
};
6768

69+
type AnalyticsDistinctIdResolution =
70+
| { status: "ready"; distinctId: string }
71+
| { status: "missing" }
72+
| { status: "error" };
73+
6874
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
6975

7076
const EMPTY_ANALYTICS_STATE: AnalyticsState = {
7177
sessionStartSent: false,
78+
firstConversationDistinctId: null,
7279
sentUserMessageIds: [],
7380
sentSkillUseIds: [],
7481
};
@@ -169,11 +176,41 @@ export class AnalyticsService {
169176
return;
170177
}
171178

179+
const analyticsDistinctId = await this.resolveAnalyticsDistinctId();
180+
if (analyticsDistinctId.status === "error") {
181+
return;
182+
}
183+
184+
const shouldSendAnalytics = analyticsDistinctId.status === "ready";
185+
const currentDistinctId =
186+
analyticsDistinctId.status === "ready"
187+
? analyticsDistinctId.distinctId
188+
: null;
189+
let stateChanged = false;
190+
172191
await this.ensureStateLoaded();
173-
const profile = await this.configStore.getLocalProfile();
192+
if (
193+
currentDistinctId &&
194+
this.state.sessionStartSent &&
195+
this.state.firstConversationDistinctId === null
196+
) {
197+
this.state.sessionStartSent = false;
198+
stateChanged = true;
199+
}
200+
201+
if (
202+
currentDistinctId &&
203+
this.state.sessionStartSent &&
204+
this.state.firstConversationDistinctId &&
205+
this.state.firstConversationDistinctId !== currentDistinctId
206+
) {
207+
this.state.sessionStartSent = false;
208+
this.state.firstConversationDistinctId = null;
209+
stateChanged = true;
210+
}
211+
174212
const sessions = await this.sessionsRuntime.listSessions();
175213
const skillLedger = await this.readSkillLedgerSources();
176-
let stateChanged = false;
177214
let firstSessionCandidate: UserMessageCandidate | null = null;
178215

179216
for (const session of sessions) {
@@ -214,16 +251,18 @@ export class AnalyticsService {
214251
continue;
215252
}
216253

217-
await this.sendAnalyticsEvent(
218-
profile.id,
219-
"user_message_sent",
220-
{
221-
channel: userMessage.channel,
222-
model_provider: userMessage.providerName,
223-
state: userMessage.state,
224-
},
225-
userMessage.timestampMs,
226-
);
254+
if (shouldSendAnalytics) {
255+
await this.sendAnalyticsEvent(
256+
analyticsDistinctId.distinctId,
257+
"user_message_sent",
258+
{
259+
channel: userMessage.channel,
260+
model_provider: userMessage.providerName,
261+
state: userMessage.state,
262+
},
263+
userMessage.timestampMs,
264+
);
265+
}
227266
this.sentUserMessageIds.add(userMessage.id);
228267
stateChanged = true;
229268
}
@@ -236,34 +275,39 @@ export class AnalyticsService {
236275
continue;
237276
}
238277

239-
await this.sendAnalyticsEvent(
240-
profile.id,
241-
"skill_use",
242-
{
243-
skill_name: skillUse.skillName,
244-
skill_source: skillUse.skillSource,
245-
channel: skillUse.channel,
246-
model_provider: skillUse.providerName,
247-
},
248-
skillUse.timestampMs,
249-
);
278+
if (shouldSendAnalytics) {
279+
await this.sendAnalyticsEvent(
280+
analyticsDistinctId.distinctId,
281+
"skill_use",
282+
{
283+
skill_name: skillUse.skillName,
284+
skill_source: skillUse.skillSource,
285+
channel: skillUse.channel,
286+
model_provider: skillUse.providerName,
287+
},
288+
skillUse.timestampMs,
289+
);
290+
}
250291
this.sentSkillUseIds.add(skillUse.id);
251292
stateChanged = true;
252293
}
253294
}
254295

255296
if (!this.state.sessionStartSent && firstSessionCandidate?.providerName) {
256-
await this.sendAnalyticsEvent(
257-
profile.id,
258-
"nexu_first_conversation_start",
259-
{
260-
channel: firstSessionCandidate.channel,
261-
model_provider: firstSessionCandidate.providerName,
262-
},
263-
firstSessionCandidate.timestampMs,
264-
);
265-
this.state.sessionStartSent = true;
266-
stateChanged = true;
297+
if (shouldSendAnalytics) {
298+
await this.sendAnalyticsEvent(
299+
analyticsDistinctId.distinctId,
300+
"nexu_first_conversation_start",
301+
{
302+
channel: firstSessionCandidate.channel,
303+
model_provider: firstSessionCandidate.providerName,
304+
},
305+
firstSessionCandidate.timestampMs,
306+
);
307+
this.state.firstConversationDistinctId = analyticsDistinctId.distinctId;
308+
this.state.sessionStartSent = true;
309+
stateChanged = true;
310+
}
267311
}
268312

269313
if (stateChanged) {
@@ -281,6 +325,10 @@ export class AnalyticsService {
281325
const parsed = JSON.parse(raw) as Partial<AnalyticsState>;
282326
this.state = {
283327
sessionStartSent: parsed.sessionStartSent === true,
328+
firstConversationDistinctId:
329+
typeof parsed.firstConversationDistinctId === "string"
330+
? parsed.firstConversationDistinctId
331+
: null,
284332
sentUserMessageIds: Array.isArray(parsed.sentUserMessageIds)
285333
? parsed.sentUserMessageIds.filter(
286334
(value): value is string => typeof value === "string",
@@ -600,6 +648,29 @@ export class AnalyticsService {
600648
return `${host.replace(/\/+$/, "")}/i/v0/e/`;
601649
}
602650

651+
private async resolveAnalyticsDistinctId(): Promise<AnalyticsDistinctIdResolution> {
652+
try {
653+
const cloudStatus = await this.configStore.getDesktopCloudStatus();
654+
const userId =
655+
typeof cloudStatus?.userId === "string"
656+
? cloudStatus.userId.trim()
657+
: "";
658+
if (!userId || userId === "desktop-local-user") {
659+
return { status: "missing" };
660+
}
661+
662+
return { status: "ready", distinctId: userId };
663+
} catch (error) {
664+
logger.warn(
665+
{
666+
error: error instanceof Error ? error.message : String(error),
667+
},
668+
"failed_to_resolve_analytics_distinct_id",
669+
);
670+
return { status: "error" };
671+
}
672+
}
673+
603674
private async sendAnalyticsEvent(
604675
distinctId: string,
605676
eventType: string,

apps/controller/src/services/skillhub/skill-dir-watcher.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export type SkillDirWatcherLogFn = (
1010
) => void;
1111

1212
const defaultLog: SkillDirWatcherLogFn = () => {};
13-
const workspaceSkillPathPattern = /^agents\/[^/]+\/skills(?:\/|$)/;
13+
const workspaceSkillPathPattern = /(?:^|\/)agents\/[^/]+\/skills(?:\/|$)/;
1414

1515
export class SkillDirWatcher {
1616
private readonly skillsDir: string;
@@ -299,7 +299,7 @@ export class SkillDirWatcher {
299299
return false;
300300
}
301301

302-
const normalized = String(fileName).replace(/\\/g, "/");
302+
const normalized = this.normalizeWorkspaceWatchPath(String(fileName));
303303
return workspaceSkillPathPattern.test(normalized);
304304
}
305305

@@ -337,7 +337,7 @@ export class SkillDirWatcher {
337337
}
338338

339339
private ensureWorkspaceSkillWatcherForPath(relativePath: string): void {
340-
const normalized = relativePath.replace(/\\/g, "/");
340+
const normalized = this.normalizeWorkspaceWatchPath(relativePath);
341341
const match = normalized.match(/^agents\/([^/]+)\//);
342342
if (!match) {
343343
return;
@@ -351,6 +351,19 @@ export class SkillDirWatcher {
351351
this.ensureWorkspaceSkillWatcher(botId);
352352
}
353353

354+
private normalizeWorkspaceWatchPath(filePath: string): string {
355+
const normalized = filePath.replace(/\\/g, "/");
356+
const match = workspaceSkillPathPattern.exec(normalized);
357+
if (!match || typeof match.index !== "number") {
358+
return normalized;
359+
}
360+
361+
const startIndex =
362+
normalized[match.index] === "/" ? match.index + 1 : match.index;
363+
364+
return normalized.slice(startIndex);
365+
}
366+
354367
private ensureWorkspaceSkillWatcher(botId: string): void {
355368
if (!this.openclawStateDir || this.workspaceSkillWatchers.has(botId)) {
356369
return;

0 commit comments

Comments
 (0)