Skip to content

Commit 29949db

Browse files
authored
chore: improve observability (#1672)
* chore: improve observability * chore: add changeset
1 parent 9c10c57 commit 29949db

File tree

9 files changed

+414
-51
lines changed

9 files changed

+414
-51
lines changed

.changeset/fruity-trees-rhyme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_compiler": patch
3+
"lingo.dev": patch
4+
---
5+
6+
Improve observability

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export default new Command()
100100
flags = parseFlags(options);
101101
} catch (parseError: any) {
102102
// Handle flag validation errors (like invalid locale codes)
103-
await trackEvent("unknown", "cmd.i18n.error", {
103+
await trackEvent(null, "cmd.i18n.error", {
104104
errorType: "validation_error",
105105
errorName: parseError.name || "ValidationError",
106106
errorMessage: parseError.message || "Invalid command line options",
@@ -581,7 +581,7 @@ export default new Command()
581581
});
582582
} else {
583583
ora.warn("Localization completed with errors.");
584-
await trackEvent(authId || "unknown", "cmd.i18n.error", {
584+
await trackEvent(authId, "cmd.i18n.error", {
585585
flags,
586586
...aggregateErrorAnalytics(
587587
errorDetails,
@@ -612,7 +612,7 @@ export default new Command()
612612
};
613613
}
614614

615-
await trackEvent(authId || "unknown", "cmd.i18n.error", {
615+
await trackEvent(authId, "cmd.i18n.error", {
616616
flags,
617617
errorType,
618618
errorName: error.name || "Error",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export default new Command()
174174
flags: ctx.flags,
175175
});
176176
} catch (error: any) {
177-
await trackEvent(authId || "unknown", "cmd.run.error", {});
177+
await trackEvent(authId, "cmd.run.error", {});
178178
// Play sad sound if sound flag is enabled
179179
if (args.sound) {
180180
await playSound("failure");

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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(authId || "status", "cmd.status.start", {
95+
trackEvent(authId, "cmd.status.start", {
9696
i18nConfig,
9797
flags,
9898
});
@@ -628,7 +628,7 @@ export default new Command()
628628
}
629629

630630
// Track successful completion
631-
trackEvent(authId || "status", "cmd.status.success", {
631+
trackEvent(authId, "cmd.status.success", {
632632
i18nConfig,
633633
flags,
634634
totalSourceKeyCount,
@@ -639,7 +639,7 @@ export default new Command()
639639
exitGracefully();
640640
} catch (error: any) {
641641
ora.fail(error.message);
642-
trackEvent(authId || "status", "cmd.status.error", {
642+
trackEvent(authId, "cmd.status.error", {
643643
flags,
644644
error: error.message,
645645
authenticated: !!authId,

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

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,80 @@
11
import pkg from "node-machine-id";
22
const { machineIdSync } = pkg;
33
import https from "https";
4+
import { getRepositoryId } from "./repository-id";
45

56
const POSTHOG_API_KEY = "phc_eR0iSoQufBxNY36k0f0T15UvHJdTfHlh8rJcxsfhfXk";
67
const POSTHOG_HOST = "eu.i.posthog.com";
7-
const POSTHOG_PATH = "/i/v0/e/"; // Correct PostHog capture endpoint
8+
const POSTHOG_PATH = "/i/v0/e/";
89
const REQUEST_TIMEOUT_MS = 1000;
10+
const TRACKING_VERSION = "2.0";
11+
12+
function determineDistinctId(providedId: string | null | undefined): {
13+
distinct_id: string;
14+
distinct_id_source: string;
15+
project_id: string | null;
16+
} {
17+
if (providedId) {
18+
const projectId = getRepositoryId();
19+
return {
20+
distinct_id: providedId,
21+
distinct_id_source: "email",
22+
project_id: projectId,
23+
};
24+
}
25+
26+
const repoId = getRepositoryId();
27+
if (repoId) {
28+
return {
29+
distinct_id: repoId,
30+
distinct_id_source: "git_repo",
31+
project_id: repoId,
32+
};
33+
}
34+
35+
const deviceId = `device-${machineIdSync()}`;
36+
if (process.env.DEBUG === "true") {
37+
console.warn(
38+
"[Tracking] Using device ID fallback. Consider using git repository for consistent tracking.",
39+
);
40+
}
41+
return {
42+
distinct_id: deviceId,
43+
distinct_id_source: "device",
44+
project_id: null,
45+
};
46+
}
947

10-
/**
11-
* Sends an analytics event to PostHog using direct HTTPS API.
12-
* This is a fire-and-forget implementation that won't block the process.
13-
*
14-
* @param distinctId - Unique identifier for the user/device
15-
* @param event - Name of the event to track
16-
* @param properties - Additional properties to attach to the event
17-
*/
1848
export default function trackEvent(
1949
distinctId: string | null | undefined,
2050
event: string,
2151
properties?: Record<string, any>,
2252
): void {
23-
// Skip tracking if explicitly disabled or in CI environment
2453
if (process.env.DO_NOT_TRACK === "1") {
2554
return;
2655
}
2756

28-
// Defer execution to next tick to avoid blocking
2957
setImmediate(() => {
3058
try {
31-
const actualId = distinctId || `device-${machineIdSync()}`;
59+
const identityInfo = determineDistinctId(distinctId);
60+
61+
if (process.env.DEBUG === "true") {
62+
console.log(
63+
`[Tracking] Event: ${event}, ID: ${identityInfo.distinct_id}, Source: ${identityInfo.distinct_id_source}`,
64+
);
65+
}
3266

33-
// PostHog expects distinct_id at the root level, not nested in properties
3467
const eventData = {
3568
api_key: POSTHOG_API_KEY,
3669
event,
37-
distinct_id: actualId,
70+
distinct_id: identityInfo.distinct_id,
3871
properties: {
3972
...properties,
4073
$lib: "lingo.dev-cli",
4174
$lib_version: process.env.npm_package_version || "unknown",
42-
// Essential debugging context only
75+
tracking_version: TRACKING_VERSION,
76+
distinct_id_source: identityInfo.distinct_id_source,
77+
project_id: identityInfo.project_id,
4378
node_version: process.version,
4479
is_ci: !!process.env.CI,
4580
debug_enabled: process.env.DEBUG === "true",
@@ -62,30 +97,25 @@ export default function trackEvent(
6297

6398
const req = https.request(options);
6499

65-
// Handle timeout by destroying the request
66100
req.on("timeout", () => {
67101
req.destroy();
68102
});
69103

70-
// Silently ignore errors to prevent crashes
71104
req.on("error", (error) => {
72105
if (process.env.DEBUG === "true") {
73106
console.error("[Tracking] Error ignored:", error.message);
74107
}
75108
});
76109

77-
// Send payload and close the request
78110
req.write(payload);
79111
req.end();
80112

81-
// Ensure cleanup after timeout
82113
setTimeout(() => {
83114
if (!req.destroyed) {
84115
req.destroy();
85116
}
86117
}, REQUEST_TIMEOUT_MS);
87118
} catch (error) {
88-
// Catch-all for any synchronous errors
89119
if (process.env.DEBUG === "true") {
90120
console.error("[Tracking] Failed to send event:", error);
91121
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { getRepositoryId, clearRepositoryIdCache } from "./repository-id";
3+
import { execSync } from "child_process";
4+
5+
vi.mock("child_process");
6+
7+
describe("getRepositoryId", () => {
8+
const originalEnv = process.env;
9+
10+
beforeEach(() => {
11+
process.env = { ...originalEnv };
12+
vi.clearAllMocks();
13+
clearRepositoryIdCache(); // Clear cache between tests
14+
});
15+
16+
afterEach(() => {
17+
process.env = originalEnv;
18+
});
19+
20+
describe("CI environment variables", () => {
21+
it("should detect GitHub repository from GITHUB_REPOSITORY", () => {
22+
process.env.GITHUB_REPOSITORY = "owner/repo";
23+
expect(getRepositoryId()).toBe("github:owner/repo");
24+
});
25+
26+
it("should detect GitLab repository from CI_PROJECT_PATH", () => {
27+
process.env.CI_PROJECT_PATH = "namespace/project";
28+
expect(getRepositoryId()).toBe("gitlab:namespace/project");
29+
});
30+
31+
it("should detect Bitbucket repository from BITBUCKET_REPO_FULL_NAME", () => {
32+
process.env.BITBUCKET_REPO_FULL_NAME = "workspace/repo";
33+
expect(getRepositoryId()).toBe("bitbucket:workspace/repo");
34+
});
35+
36+
it("should prioritize CI environment variables over git remote", () => {
37+
process.env.GITHUB_REPOSITORY = "owner/repo";
38+
vi.mocked(execSync).mockReturnValue(
39+
"git@gitlab.com:other/project.git" as any,
40+
);
41+
expect(getRepositoryId()).toBe("github:owner/repo");
42+
});
43+
});
44+
45+
describe("git remote URL parsing", () => {
46+
it("should parse GitHub SSH URL", () => {
47+
vi.mocked(execSync).mockReturnValue("git@github.com:owner/repo.git" as any);
48+
expect(getRepositoryId()).toBe("github:owner/repo");
49+
});
50+
51+
it("should parse GitHub HTTPS URL", () => {
52+
vi.mocked(execSync).mockReturnValue(
53+
"https://github.com/owner/repo.git" as any,
54+
);
55+
expect(getRepositoryId()).toBe("github:owner/repo");
56+
});
57+
58+
it("should parse GitLab SSH URL", () => {
59+
vi.mocked(execSync).mockReturnValue(
60+
"git@gitlab.com:namespace/project.git" as any,
61+
);
62+
expect(getRepositoryId()).toBe("gitlab:namespace/project");
63+
});
64+
65+
it("should parse GitLab HTTPS URL", () => {
66+
vi.mocked(execSync).mockReturnValue(
67+
"https://gitlab.com/namespace/project.git" as any,
68+
);
69+
expect(getRepositoryId()).toBe("gitlab:namespace/project");
70+
});
71+
72+
it("should parse Bitbucket SSH URL", () => {
73+
vi.mocked(execSync).mockReturnValue(
74+
"git@bitbucket.org:workspace/repo.git" as any,
75+
);
76+
expect(getRepositoryId()).toBe("bitbucket:workspace/repo");
77+
});
78+
79+
it("should parse Bitbucket HTTPS URL", () => {
80+
vi.mocked(execSync).mockReturnValue(
81+
"https://bitbucket.org/workspace/repo.git" as any,
82+
);
83+
expect(getRepositoryId()).toBe("bitbucket:workspace/repo");
84+
});
85+
86+
it("should parse self-hosted git URL with generic prefix", () => {
87+
vi.mocked(execSync).mockReturnValue(
88+
"git@custom-git.company.com:team/project.git" as any,
89+
);
90+
expect(getRepositoryId()).toBe("git:team/project");
91+
});
92+
93+
it("should handle URLs without .git extension", () => {
94+
vi.mocked(execSync).mockReturnValue("git@github.com:owner/repo" as any);
95+
expect(getRepositoryId()).toBe("github:owner/repo");
96+
});
97+
98+
it("should return null when git command fails", () => {
99+
vi.mocked(execSync).mockImplementation(() => {
100+
throw new Error("not a git repository");
101+
});
102+
expect(getRepositoryId()).toBe(null);
103+
});
104+
105+
it("should return null when git remote is empty", () => {
106+
vi.mocked(execSync).mockReturnValue("" as any);
107+
expect(getRepositoryId()).toBe(null);
108+
});
109+
110+
it("should return null when git remote URL is invalid", () => {
111+
vi.mocked(execSync).mockReturnValue("invalid-url" as any);
112+
expect(getRepositoryId()).toBe(null);
113+
});
114+
});
115+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { execSync } from "child_process";
2+
3+
let cachedGitRepoId: string | null | undefined = undefined;
4+
5+
export function clearRepositoryIdCache(): void {
6+
cachedGitRepoId = undefined;
7+
}
8+
export function getRepositoryId(): string | null {
9+
const ciRepoId = getCIRepositoryId();
10+
if (ciRepoId) return ciRepoId;
11+
12+
const gitRepoId = getGitRepositoryId();
13+
if (gitRepoId) return gitRepoId;
14+
15+
return null;
16+
}
17+
18+
function getCIRepositoryId(): string | null {
19+
if (process.env.GITHUB_REPOSITORY) {
20+
return `github:${process.env.GITHUB_REPOSITORY}`;
21+
}
22+
23+
if (process.env.CI_PROJECT_PATH) {
24+
return `gitlab:${process.env.CI_PROJECT_PATH}`;
25+
}
26+
27+
if (process.env.BITBUCKET_REPO_FULL_NAME) {
28+
return `bitbucket:${process.env.BITBUCKET_REPO_FULL_NAME}`;
29+
}
30+
31+
return null;
32+
}
33+
34+
function getGitRepositoryId(): string | null {
35+
if (cachedGitRepoId !== undefined) {
36+
return cachedGitRepoId;
37+
}
38+
39+
try {
40+
const remoteUrl = execSync("git config --get remote.origin.url", {
41+
encoding: "utf8",
42+
stdio: ["pipe", "pipe", "ignore"],
43+
}).trim();
44+
45+
if (!remoteUrl) {
46+
cachedGitRepoId = null;
47+
return null;
48+
}
49+
50+
cachedGitRepoId = parseGitUrl(remoteUrl);
51+
return cachedGitRepoId;
52+
} catch {
53+
cachedGitRepoId = null;
54+
return null;
55+
}
56+
}
57+
58+
function parseGitUrl(url: string): string | null {
59+
const cleanUrl = url.replace(/\.git$/, "");
60+
61+
let platform: string | null = null;
62+
if (cleanUrl.includes("github.com")) {
63+
platform = "github";
64+
} else if (cleanUrl.includes("gitlab.com")) {
65+
platform = "gitlab";
66+
} else if (cleanUrl.includes("bitbucket.org")) {
67+
platform = "bitbucket";
68+
}
69+
70+
const sshMatch = cleanUrl.match(/[@:]([^:/@]+\/[^:/@]+)$/);
71+
const httpsMatch = cleanUrl.match(/\/([^/]+\/[^/]+)$/);
72+
73+
const repoPath = sshMatch?.[1] || httpsMatch?.[1];
74+
75+
if (!repoPath) return null;
76+
77+
if (platform) {
78+
return `${platform}:${repoPath}`;
79+
}
80+
81+
return `git:${repoPath}`;
82+
}

0 commit comments

Comments
 (0)