Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/lazy-beds-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"lingo.dev": patch
"@lingo.dev/_sdk": patch
---

Migrate PostHog tracking identity from email to database user ID
16 changes: 8 additions & 8 deletions packages/cli/src/cli/cmd/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import externalEditor from "external-editor";
import updateGitignore from "../utils/update-gitignore";
import createProcessor from "../processor";
import { withExponentialBackoff } from "../utils/exp-backoff";
import trackEvent from "../utils/observability";
import trackEvent, { UserIdentity } from "../utils/observability";
import { createDeltaProcessor } from "../utils/delta";

export default new Command()
Expand Down Expand Up @@ -135,7 +135,7 @@ export default new Command()
}

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

if (isByokMode) {
email = null;
userIdentity = null;
ora.succeed("Using external provider (BYOK mode)");
} else {
const auth = await validateAuth(settings);
email = auth.email;
userIdentity = { email: auth.email, id: auth.id };
ora.succeed(`Authenticated as ${auth.email}`);
Comment thread
AndreyHirsa marked this conversation as resolved.
}

await trackEvent(email, "cmd.i18n.start", {
await trackEvent(userIdentity, "cmd.i18n.start", {
i18nConfig,
flags,
});
Expand Down Expand Up @@ -587,7 +587,7 @@ export default new Command()
console.log();
if (!hasErrors) {
ora.succeed("Localization completed.");
await trackEvent(email, "cmd.i18n.success", {
await trackEvent(userIdentity, "cmd.i18n.success", {
i18nConfig: {
sourceLocale: i18nConfig!.locale.source,
targetLocales: i18nConfig!.locale.targets,
Expand All @@ -602,7 +602,7 @@ export default new Command()
} else {
ora.warn("Localization completed with errors.");
process.exitCode = 1;
await trackEvent(email, "cmd.i18n.error", {
await trackEvent(userIdentity, "cmd.i18n.error", {
flags,
...aggregateErrorAnalytics(
errorDetails,
Expand Down Expand Up @@ -634,7 +634,7 @@ export default new Command()
};
}

await trackEvent(email, "cmd.i18n.error", {
await trackEvent(userIdentity, "cmd.i18n.error", {
flags,
errorType,
errorName: error.name || "Error",
Expand Down
13 changes: 9 additions & 4 deletions packages/cli/src/cli/cmd/run/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { CmdRunContext } from "./_types";
import { UserIdentity } from "../../utils/observability";

/**
* Determines the user's email for tracking purposes.
* Determines the user's identity for tracking purposes.
* Returns null if using BYOK mode or if authentication fails.
*/
export async function determineEmail(
export async function determineUserIdentity(
ctx: CmdRunContext,
): Promise<string | null> {
): Promise<UserIdentity> {
const isByokMode = !!ctx.config?.provider;

if (isByokMode) {
return null;
} else {
try {
const authStatus = await ctx.localizer?.checkAuth();
return authStatus?.username || null;
if (!authStatus?.username || !authStatus?.userId) return null;
return {
email: authStatus.username,
id: authStatus.userId,
};
} catch {
return null;
}
Expand Down
16 changes: 8 additions & 8 deletions packages/cli/src/cli/cmd/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {
pauseIfDebug,
renderSummary,
} from "../../utils/ui";
import trackEvent from "../../utils/observability";
import { determineEmail } from "./_utils";
import trackEvent, { UserIdentity } from "../../utils/observability";
import { determineUserIdentity } from "./_utils";

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

Expand Down Expand Up @@ -124,7 +124,7 @@ export default new Command()
"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",
)
.action(async (args) => {
let email: string | null = null;
let userIdentity: UserIdentity = null;
try {
const ctx: CmdRunContext = {
flags: flagsSchema.parse(args),
Expand All @@ -143,9 +143,9 @@ export default new Command()

await setup(ctx);

email = await determineEmail(ctx);
userIdentity = await determineUserIdentity(ctx);

await trackEvent(email, "cmd.run.start", {
await trackEvent(userIdentity, "cmd.run.start", {
config: ctx.config,
flags: ctx.flags,
});
Expand Down Expand Up @@ -176,16 +176,16 @@ export default new Command()
await watch(ctx);
}

await trackEvent(email, "cmd.run.success", {
await trackEvent(userIdentity, "cmd.run.success", {
config: ctx.config,
flags: ctx.flags,
});
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (error: any) {
await trackEvent(email, "cmd.run.error", {
await trackEvent(userIdentity, "cmd.run.error", {
flags: args,
error: error.message,
authenticated: !!email,
authenticated: !!userIdentity,
});
await new Promise((resolve) => setTimeout(resolve, 50));
// Play sad sound if sound flag is enabled
Expand Down
36 changes: 21 additions & 15 deletions packages/cli/src/cli/cmd/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { getBuckets } from "../utils/buckets";
import chalk from "chalk";
import Table from "cli-table3";
import { createDeltaProcessor } from "../utils/delta";
import trackEvent from "../utils/observability";
import trackEvent, { UserIdentity } from "../utils/observability";
import { minimatch } from "minimatch";
import { exitGracefully } from "../utils/exit-gracefully";

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

try {
ora.start("Loading configuration...");
Expand All @@ -76,7 +76,7 @@ export default new Command()
ora.start("Checking authentication status...");
const auth = await tryAuthenticate(settings);
if (auth) {
email = auth.email;
userIdentity = { email: auth.email, id: auth.id };
ora.succeed(`Authenticated as ${auth.email}`);
Comment thread
AndreyHirsa marked this conversation as resolved.
} else {
ora.info(
Expand All @@ -92,7 +92,7 @@ export default new Command()
ora.succeed("Localization configuration is valid");

// Track event with or without authentication
trackEvent(email, "cmd.status.start", {
trackEvent(userIdentity, "cmd.status.start", {
i18nConfig,
flags,
});
Expand Down Expand Up @@ -356,10 +356,11 @@ export default new Command()
)} (${completeKeys.length}/${totalKeysInFile} keys)`,
);
} else {
const message = `[${sourceLocale} -> ${targetLocale}] ${parseFloat(completionPercent) > 50
const message = `[${sourceLocale} -> ${targetLocale}] ${
parseFloat(completionPercent) > 50
? chalk.yellow(`${completionPercent}% complete`)
: chalk.red(`${completionPercent}% complete`)
} (${completeKeys.length}/${totalKeysInFile} keys)`;
} (${completeKeys.length}/${totalKeysInFile} keys)`;

bucketOra.succeed(message);

Expand All @@ -369,7 +370,8 @@ export default new Command()
` ${chalk.red(`Missing:`)} ${missingKeys.length} keys, ~${wordsToTranslate} words`,
);
console.log(
` ${chalk.red(`Missing:`)} ${missingKeys.length
` ${chalk.red(`Missing:`)} ${
missingKeys.length
} keys, ~${wordsToTranslate} words`,
);
console.log(
Expand All @@ -382,7 +384,8 @@ export default new Command()
}
if (updatedKeys.length > 0) {
console.log(
` ${chalk.yellow(`Updated:`)} ${updatedKeys.length
` ${chalk.yellow(`Updated:`)} ${
updatedKeys.length
} keys that changed in source`,
);
}
Expand Down Expand Up @@ -536,7 +539,8 @@ export default new Command()

console.log(chalk.bold(`\n• ${path}:`));
console.log(
` ${stats.sourceKeys
` ${
stats.sourceKeys
} source keys, ~${stats.wordCount.toLocaleString()} source words`,
);

Expand Down Expand Up @@ -603,14 +607,16 @@ export default new Command()

if (missingLanguages.length > 0) {
console.log(
`• ${chalk.yellow(missingLanguages.join(", "))} ${missingLanguages.length === 1 ? "has" : "have"
`• ${chalk.yellow(missingLanguages.join(", "))} ${
missingLanguages.length === 1 ? "has" : "have"
} no translations yet`,
);
}

if (completeLanguages.length > 0) {
console.log(
`• ${chalk.green(completeLanguages.join(", "))} ${completeLanguages.length === 1 ? "is" : "are"
`• ${chalk.green(completeLanguages.join(", "))} ${
completeLanguages.length === 1 ? "is" : "are"
} completely translated`,
);
}
Expand All @@ -624,22 +630,22 @@ export default new Command()
}

// Track successful completion
trackEvent(email, "cmd.status.success", {
trackEvent(userIdentity, "cmd.status.success", {
i18nConfig,
flags,
totalSourceKeyCount,
languageStats,
totalWordsToTranslate,
authenticated: !!email,
authenticated: !!userIdentity,
});
await new Promise((resolve) => setTimeout(resolve, 50));
exitGracefully();
} catch (error: any) {
ora.fail(error.message);
trackEvent(email, "cmd.status.error", {
trackEvent(userIdentity, "cmd.status.error", {
flags,
error: error.message,
authenticated: !!email,
authenticated: !!userIdentity,
});
await new Promise((resolve) => setTimeout(resolve, 50));
process.exit(1);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/localizer/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ILocalizer {
checkAuth: () => Promise<{
authenticated: boolean;
username?: string;
userId?: string;
error?: string;
}>;
validateSettings?: () => Promise<{ valid: boolean; error?: string }>;
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/cli/localizer/lingodotdev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ export default function createLingoDotDevLocalizer(
"Invalid API key. Run `lingo.dev login` or check your LINGO_API_KEY.",
};
}
return { authenticated: true, username: response.email };
return {
authenticated: true,
username: response.email,
userId: response.id,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
Expand Down
36 changes: 22 additions & 14 deletions packages/cli/src/cli/utils/observability.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import pkg from "node-machine-id";
const { machineIdSync } = pkg;
import https from "https";
import crypto from "crypto";
import { getOrgId } from "./org-id";

const POSTHOG_API_KEY = "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk";
Expand All @@ -10,17 +9,22 @@ const POSTHOG_PATH = "/i/v0/e/";
const REQUEST_TIMEOUT_MS = 3000;
const TRACKING_VERSION = "2.0";

function determineDistinctId(email: string | null | undefined): {
export type UserIdentity = {
email: string;
id: string;
} | null;

function determineDistinctId(user: UserIdentity): {
distinct_id: string;
distinct_id_source: string;
org_id: string | null;
} {
const orgId = getOrgId();

if (email) {
if (user) {
return {
distinct_id: email,
distinct_id_source: "email",
distinct_id: user.id,
distinct_id_source: "database_id",
org_id: orgId,
};
}
Expand All @@ -47,7 +51,7 @@ function determineDistinctId(email: string | null | undefined): {
}

export default function trackEvent(
email: string | null | undefined,
user: UserIdentity,
event: string,
properties?: Record<string, any>,
): void {
Expand All @@ -57,7 +61,7 @@ export default function trackEvent(

setImmediate(() => {
try {
const identityInfo = determineDistinctId(email);
const identityInfo = determineDistinctId(user);

if (process.env.DEBUG === "true") {
console.log(
Expand All @@ -71,7 +75,10 @@ export default function trackEvent(
distinct_id: identityInfo.distinct_id,
properties: {
...properties,
$set: { ...(properties?.$set || {}), ...(email ? { email } : {}) },
$set: {
...(properties?.$set || {}),
...(user ? { email: user.email } : {}),
},
$lib: "lingo.dev-cli",
$lib_version: process.env.npm_package_version || "unknown",
tracking_version: TRACKING_VERSION,
Expand Down Expand Up @@ -112,15 +119,14 @@ export default function trackEvent(
req.write(payload);
req.end();

// TODO: remove after 2026-03-25 — temporary alias to merge old hashed distinct_ids with new raw email
if (email) {
const hashedEmail = crypto.createHash("sha256").update(email).digest("hex");
// TODO: remove after 2026-04-30 — temporary alias to merge old email-based distinct_ids with database user ID
if (user) {
const aliasData = JSON.stringify({
api_key: POSTHOG_API_KEY,
event: "$create_alias",
distinct_id: email,
distinct_id: user.id,
properties: {
alias: hashedEmail,
alias: user.email,
},
timestamp: new Date().toISOString(),
});
Expand All @@ -136,7 +142,9 @@ export default function trackEvent(
aliasReq.on("error", () => {});
aliasReq.write(aliasData);
aliasReq.end();
setTimeout(() => { if (!aliasReq.destroyed) aliasReq.destroy(); }, REQUEST_TIMEOUT_MS);
setTimeout(() => {
if (!aliasReq.destroyed) aliasReq.destroy();
}, REQUEST_TIMEOUT_MS);
}

setTimeout(() => {
Expand Down
Loading
Loading