Skip to content

Commit 10e6364

Browse files
authored
feat: posthog distinct id migration (#2062)
* feat: use database user ID as PostHog distinct_id instead of email * fix: revert jsom demo changes * fix: revert jsom demo changes * fix: don't cache failed /users/me identity * fix: prevent override and ensure posthog shutdown on error
1 parent 336eeae commit 10e6364

File tree

10 files changed

+131
-82
lines changed

10 files changed

+131
-82
lines changed

.changeset/lazy-beds-lay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"lingo.dev": patch
3+
"@lingo.dev/_sdk": patch
4+
---
5+
6+
Migrate PostHog tracking identity from email to database user ID

packages/cli/src/cli/cmd/i18n.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import externalEditor from "external-editor";
3434
import updateGitignore from "../utils/update-gitignore";
3535
import createProcessor from "../processor";
3636
import { withExponentialBackoff } from "../utils/exp-backoff";
37-
import trackEvent from "../utils/observability";
37+
import trackEvent, { UserIdentity } from "../utils/observability";
3838
import { createDeltaProcessor } from "../utils/delta";
3939

4040
export default new Command()
@@ -135,7 +135,7 @@ export default new Command()
135135
}
136136

137137
let hasErrors = false;
138-
let email: string | null = null;
138+
let userIdentity: UserIdentity = null;
139139
const errorDetails: ErrorDetail[] = [];
140140
try {
141141
ora.start("Loading configuration...");
@@ -151,15 +151,15 @@ export default new Command()
151151
const isByokMode = !!i18nConfig?.provider;
152152

153153
if (isByokMode) {
154-
email = null;
154+
userIdentity = null;
155155
ora.succeed("Using external provider (BYOK mode)");
156156
} else {
157157
const auth = await validateAuth(settings);
158-
email = auth.email;
158+
userIdentity = { email: auth.email, id: auth.id };
159159
ora.succeed(`Authenticated as ${auth.email}`);
160160
}
161161

162-
await trackEvent(email, "cmd.i18n.start", {
162+
await trackEvent(userIdentity, "cmd.i18n.start", {
163163
i18nConfig,
164164
flags,
165165
});
@@ -587,7 +587,7 @@ export default new Command()
587587
console.log();
588588
if (!hasErrors) {
589589
ora.succeed("Localization completed.");
590-
await trackEvent(email, "cmd.i18n.success", {
590+
await trackEvent(userIdentity, "cmd.i18n.success", {
591591
i18nConfig: {
592592
sourceLocale: i18nConfig!.locale.source,
593593
targetLocales: i18nConfig!.locale.targets,
@@ -602,7 +602,7 @@ export default new Command()
602602
} else {
603603
ora.warn("Localization completed with errors.");
604604
process.exitCode = 1;
605-
await trackEvent(email, "cmd.i18n.error", {
605+
await trackEvent(userIdentity, "cmd.i18n.error", {
606606
flags,
607607
...aggregateErrorAnalytics(
608608
errorDetails,
@@ -634,7 +634,7 @@ export default new Command()
634634
};
635635
}
636636

637-
await trackEvent(email, "cmd.i18n.error", {
637+
await trackEvent(userIdentity, "cmd.i18n.error", {
638638
flags,
639639
errorType,
640640
errorName: error.name || "Error",

packages/cli/src/cli/cmd/run/_utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
import { CmdRunContext } from "./_types";
2+
import { UserIdentity } from "../../utils/observability";
23

34
/**
4-
* Determines the user's email for tracking purposes.
5+
* Determines the user's identity for tracking purposes.
56
* Returns null if using BYOK mode or if authentication fails.
67
*/
7-
export async function determineEmail(
8+
export async function determineUserIdentity(
89
ctx: CmdRunContext,
9-
): Promise<string | null> {
10+
): Promise<UserIdentity> {
1011
const isByokMode = !!ctx.config?.provider;
1112

1213
if (isByokMode) {
1314
return null;
1415
} else {
1516
try {
1617
const authStatus = await ctx.localizer?.checkAuth();
17-
return authStatus?.username || null;
18+
if (!authStatus?.username || !authStatus?.userId) return null;
19+
return {
20+
email: authStatus.username,
21+
id: authStatus.userId,
22+
};
1823
} catch {
1924
return null;
2025
}

packages/cli/src/cli/cmd/run/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
pauseIfDebug,
1919
renderSummary,
2020
} from "../../utils/ui";
21-
import trackEvent from "../../utils/observability";
22-
import { determineEmail } from "./_utils";
21+
import trackEvent, { UserIdentity } from "../../utils/observability";
22+
import { determineUserIdentity } from "./_utils";
2323

2424
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2525

@@ -124,7 +124,7 @@ export default new Command()
124124
"Enable pseudo-localization mode: automatically pseudo-translates all extracted strings with accented characters and visual markers without calling any external API. Useful for testing UI internationalization readiness",
125125
)
126126
.action(async (args) => {
127-
let email: string | null = null;
127+
let userIdentity: UserIdentity = null;
128128
try {
129129
const ctx: CmdRunContext = {
130130
flags: flagsSchema.parse(args),
@@ -143,9 +143,9 @@ export default new Command()
143143

144144
await setup(ctx);
145145

146-
email = await determineEmail(ctx);
146+
userIdentity = await determineUserIdentity(ctx);
147147

148-
await trackEvent(email, "cmd.run.start", {
148+
await trackEvent(userIdentity, "cmd.run.start", {
149149
config: ctx.config,
150150
flags: ctx.flags,
151151
});
@@ -176,16 +176,16 @@ export default new Command()
176176
await watch(ctx);
177177
}
178178

179-
await trackEvent(email, "cmd.run.success", {
179+
await trackEvent(userIdentity, "cmd.run.success", {
180180
config: ctx.config,
181181
flags: ctx.flags,
182182
});
183183
await new Promise((resolve) => setTimeout(resolve, 50));
184184
} catch (error: any) {
185-
await trackEvent(email, "cmd.run.error", {
185+
await trackEvent(userIdentity, "cmd.run.error", {
186186
flags: args,
187187
error: error.message,
188-
authenticated: !!email,
188+
authenticated: !!userIdentity,
189189
});
190190
await new Promise((resolve) => setTimeout(resolve, 50));
191191
// Play sad sound if sound flag is enabled

packages/cli/src/cli/cmd/status.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { getBuckets } from "../utils/buckets";
1818
import chalk from "chalk";
1919
import Table from "cli-table3";
2020
import { createDeltaProcessor } from "../utils/delta";
21-
import trackEvent from "../utils/observability";
21+
import trackEvent, { UserIdentity } from "../utils/observability";
2222
import { minimatch } from "minimatch";
2323
import { exitGracefully } from "../utils/exit-gracefully";
2424

@@ -63,7 +63,7 @@ export default new Command()
6363
.action(async function (options) {
6464
const ora = Ora();
6565
const flags = parseFlags(options);
66-
let email: string | null = null;
66+
let userIdentity: UserIdentity = null;
6767

6868
try {
6969
ora.start("Loading configuration...");
@@ -76,7 +76,7 @@ export default new Command()
7676
ora.start("Checking authentication status...");
7777
const auth = await tryAuthenticate(settings);
7878
if (auth) {
79-
email = auth.email;
79+
userIdentity = { email: auth.email, id: auth.id };
8080
ora.succeed(`Authenticated as ${auth.email}`);
8181
} else {
8282
ora.info(
@@ -92,7 +92,7 @@ export default new Command()
9292
ora.succeed("Localization configuration is valid");
9393

9494
// Track event with or without authentication
95-
trackEvent(email, "cmd.status.start", {
95+
trackEvent(userIdentity, "cmd.status.start", {
9696
i18nConfig,
9797
flags,
9898
});
@@ -356,10 +356,11 @@ export default new Command()
356356
)} (${completeKeys.length}/${totalKeysInFile} keys)`,
357357
);
358358
} else {
359-
const message = `[${sourceLocale} -> ${targetLocale}] ${parseFloat(completionPercent) > 50
359+
const message = `[${sourceLocale} -> ${targetLocale}] ${
360+
parseFloat(completionPercent) > 50
360361
? chalk.yellow(`${completionPercent}% complete`)
361362
: chalk.red(`${completionPercent}% complete`)
362-
} (${completeKeys.length}/${totalKeysInFile} keys)`;
363+
} (${completeKeys.length}/${totalKeysInFile} keys)`;
363364

364365
bucketOra.succeed(message);
365366

@@ -369,7 +370,8 @@ export default new Command()
369370
` ${chalk.red(`Missing:`)} ${missingKeys.length} keys, ~${wordsToTranslate} words`,
370371
);
371372
console.log(
372-
` ${chalk.red(`Missing:`)} ${missingKeys.length
373+
` ${chalk.red(`Missing:`)} ${
374+
missingKeys.length
373375
} keys, ~${wordsToTranslate} words`,
374376
);
375377
console.log(
@@ -382,7 +384,8 @@ export default new Command()
382384
}
383385
if (updatedKeys.length > 0) {
384386
console.log(
385-
` ${chalk.yellow(`Updated:`)} ${updatedKeys.length
387+
` ${chalk.yellow(`Updated:`)} ${
388+
updatedKeys.length
386389
} keys that changed in source`,
387390
);
388391
}
@@ -536,7 +539,8 @@ export default new Command()
536539

537540
console.log(chalk.bold(`\n• ${path}:`));
538541
console.log(
539-
` ${stats.sourceKeys
542+
` ${
543+
stats.sourceKeys
540544
} source keys, ~${stats.wordCount.toLocaleString()} source words`,
541545
);
542546

@@ -603,14 +607,16 @@ export default new Command()
603607

604608
if (missingLanguages.length > 0) {
605609
console.log(
606-
`• ${chalk.yellow(missingLanguages.join(", "))} ${missingLanguages.length === 1 ? "has" : "have"
610+
`• ${chalk.yellow(missingLanguages.join(", "))} ${
611+
missingLanguages.length === 1 ? "has" : "have"
607612
} no translations yet`,
608613
);
609614
}
610615

611616
if (completeLanguages.length > 0) {
612617
console.log(
613-
`• ${chalk.green(completeLanguages.join(", "))} ${completeLanguages.length === 1 ? "is" : "are"
618+
`• ${chalk.green(completeLanguages.join(", "))} ${
619+
completeLanguages.length === 1 ? "is" : "are"
614620
} completely translated`,
615621
);
616622
}
@@ -624,22 +630,22 @@ export default new Command()
624630
}
625631

626632
// Track successful completion
627-
trackEvent(email, "cmd.status.success", {
633+
trackEvent(userIdentity, "cmd.status.success", {
628634
i18nConfig,
629635
flags,
630636
totalSourceKeyCount,
631637
languageStats,
632638
totalWordsToTranslate,
633-
authenticated: !!email,
639+
authenticated: !!userIdentity,
634640
});
635641
await new Promise((resolve) => setTimeout(resolve, 50));
636642
exitGracefully();
637643
} catch (error: any) {
638644
ora.fail(error.message);
639-
trackEvent(email, "cmd.status.error", {
645+
trackEvent(userIdentity, "cmd.status.error", {
640646
flags,
641647
error: error.message,
642-
authenticated: !!email,
648+
authenticated: !!userIdentity,
643649
});
644650
await new Promise((resolve) => setTimeout(resolve, 50));
645651
process.exit(1);

packages/cli/src/cli/localizer/_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ILocalizer {
2121
checkAuth: () => Promise<{
2222
authenticated: boolean;
2323
username?: string;
24+
userId?: string;
2425
error?: string;
2526
}>;
2627
validateSettings?: () => Promise<{ valid: boolean; error?: string }>;

packages/cli/src/cli/localizer/lingodotdev.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ export default function createLingoDotDevLocalizer(
4646
"Invalid API key. Run `lingo.dev login` or check your LINGO_API_KEY.",
4747
};
4848
}
49-
return { authenticated: true, username: response.email };
49+
return {
50+
authenticated: true,
51+
username: response.email,
52+
userId: response.id,
53+
};
5054
} catch (error) {
5155
const errorMessage =
5256
error instanceof Error ? error.message : String(error);

packages/cli/src/cli/utils/observability.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import pkg from "node-machine-id";
22
const { machineIdSync } = pkg;
33
import https from "https";
4-
import crypto from "crypto";
54
import { getOrgId } from "./org-id";
65

76
const POSTHOG_API_KEY = "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk";
@@ -10,17 +9,22 @@ const POSTHOG_PATH = "/i/v0/e/";
109
const REQUEST_TIMEOUT_MS = 3000;
1110
const TRACKING_VERSION = "2.0";
1211

13-
function determineDistinctId(email: string | null | undefined): {
12+
export type UserIdentity = {
13+
email: string;
14+
id: string;
15+
} | null;
16+
17+
function determineDistinctId(user: UserIdentity): {
1418
distinct_id: string;
1519
distinct_id_source: string;
1620
org_id: string | null;
1721
} {
1822
const orgId = getOrgId();
1923

20-
if (email) {
24+
if (user) {
2125
return {
22-
distinct_id: email,
23-
distinct_id_source: "email",
26+
distinct_id: user.id,
27+
distinct_id_source: "database_id",
2428
org_id: orgId,
2529
};
2630
}
@@ -47,7 +51,7 @@ function determineDistinctId(email: string | null | undefined): {
4751
}
4852

4953
export default function trackEvent(
50-
email: string | null | undefined,
54+
user: UserIdentity,
5155
event: string,
5256
properties?: Record<string, any>,
5357
): void {
@@ -57,7 +61,7 @@ export default function trackEvent(
5761

5862
setImmediate(() => {
5963
try {
60-
const identityInfo = determineDistinctId(email);
64+
const identityInfo = determineDistinctId(user);
6165

6266
if (process.env.DEBUG === "true") {
6367
console.log(
@@ -71,7 +75,10 @@ export default function trackEvent(
7175
distinct_id: identityInfo.distinct_id,
7276
properties: {
7377
...properties,
74-
$set: { ...(properties?.$set || {}), ...(email ? { email } : {}) },
78+
$set: {
79+
...(properties?.$set || {}),
80+
...(user ? { email: user.email } : {}),
81+
},
7582
$lib: "lingo.dev-cli",
7683
$lib_version: process.env.npm_package_version || "unknown",
7784
tracking_version: TRACKING_VERSION,
@@ -112,15 +119,14 @@ export default function trackEvent(
112119
req.write(payload);
113120
req.end();
114121

115-
// TODO: remove after 2026-03-25 — temporary alias to merge old hashed distinct_ids with new raw email
116-
if (email) {
117-
const hashedEmail = crypto.createHash("sha256").update(email).digest("hex");
122+
// TODO: remove after 2026-04-30 — temporary alias to merge old email-based distinct_ids with database user ID
123+
if (user) {
118124
const aliasData = JSON.stringify({
119125
api_key: POSTHOG_API_KEY,
120126
event: "$create_alias",
121-
distinct_id: email,
127+
distinct_id: user.id,
122128
properties: {
123-
alias: hashedEmail,
129+
alias: user.email,
124130
},
125131
timestamp: new Date().toISOString(),
126132
});
@@ -136,7 +142,9 @@ export default function trackEvent(
136142
aliasReq.on("error", () => {});
137143
aliasReq.write(aliasData);
138144
aliasReq.end();
139-
setTimeout(() => { if (!aliasReq.destroyed) aliasReq.destroy(); }, REQUEST_TIMEOUT_MS);
145+
setTimeout(() => {
146+
if (!aliasReq.destroyed) aliasReq.destroy();
147+
}, REQUEST_TIMEOUT_MS);
140148
}
141149

142150
setTimeout(() => {

0 commit comments

Comments
 (0)