Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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