Skip to content

Commit f421700

Browse files
authored
feat: adds telemetry to cli (#125)
1 parent 079c6ff commit f421700

9 files changed

Lines changed: 195 additions & 45 deletions

File tree

src/actions/portal/toc/new-toc.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ import { TocEndpoint, TocGroup, TocModel } from "../../../types/toc/toc.js";
1010
import { PortalService } from "../../../infrastructure/services/portal-service.js";
1111
import { DirectoryPath } from "../../../types/file/directoryPath.js";
1212

13-
const DEFAULT_TOC_FILENAME = "toc.yml";
14-
const APIMATIC_BUILD_FILENAME = "APIMATIC-BUILD.json";
15-
1613
export class PortalNewTocAction {
1714
private readonly prompts: PortalNewTocPrompts;
1815
private readonly sdlParser: SdlParser;
1916
private readonly tocGenerator: TocStructureGenerator;
2017
private readonly contentParser: TocContentParser;
18+
private readonly DEFAULT_TOC_FILENAME: string = "toc.yml" as const;
19+
private readonly APIMATIC_BUILD_FILENAME: string = "APIMATIC-BUILD.json" as const;
2120

2221
constructor() {
2322
this.prompts = new PortalNewTocPrompts();
@@ -26,7 +25,7 @@ export class PortalNewTocAction {
2625
this.contentParser = new TocContentParser();
2726
}
2827

29-
async createToc(
28+
public async createToc(
3029
buildDirectory: DirectoryPath,
3130
configDir: string,
3231
tocDirectory?: DirectoryPath,
@@ -36,7 +35,7 @@ export class PortalNewTocAction {
3635
): Promise<Result<string, string>> {
3736
try {
3837
const tocDir = await this.getDestinationPath(buildDirectory, tocDirectory);
39-
const tocPath = path.join(tocDir, DEFAULT_TOC_FILENAME);
38+
const tocPath = path.join(tocDir, this.DEFAULT_TOC_FILENAME);
4039
const tocCheckResult = await this.handleExistingToc(tocPath, force);
4140
if (!tocCheckResult.isSuccess()) {
4241
return tocCheckResult;
@@ -64,7 +63,7 @@ export class PortalNewTocAction {
6463
this.prompts.displayOutroMessage(tocPath);
6564
return Result.success(tocPath);
6665
} catch (error) {
67-
this.prompts.logError(getMessageInRedColor(`${error}`));
66+
this.prompts.logError(getMessageInRedColor(`${(error as Error).message}`));
6867
return Result.failure(`❌ An unexpected error occurred while generating the TOC file.`);
6968
}
7069
}
@@ -143,7 +142,7 @@ export class PortalNewTocAction {
143142
}
144143

145144
private async getContentFolderPath(buildDirectory: DirectoryPath): Promise<string> {
146-
const buildFilePath = path.join(buildDirectory.toString(), APIMATIC_BUILD_FILENAME);
145+
const buildFilePath = path.join(buildDirectory.toString(), this.APIMATIC_BUILD_FILENAME);
147146
const defaultContentFolder = path.join(buildDirectory.toString(), "content");
148147

149148
if (!(await fsExtra.pathExists(buildFilePath))) {
@@ -163,7 +162,7 @@ export class PortalNewTocAction {
163162
}
164163

165164
private async getSpecFolderPath(buildDirectory: DirectoryPath): Promise<string> {
166-
const buildFilePath = path.join(buildDirectory.toString(), APIMATIC_BUILD_FILENAME);
165+
const buildFilePath = path.join(buildDirectory.toString(), this.APIMATIC_BUILD_FILENAME);
167166
const defaultSpecFolder = path.join(buildDirectory.toString(), "spec");
168167

169168
if (!(await fsExtra.pathExists(buildFilePath))) {

src/client-utils/auth-manager.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as path from "path";
2-
import fsExtra from "fs-extra";
2+
import fs from "fs-extra";
33

44
export type AuthInfo = {
55
email: string;
66
authKey: string;
7+
APIMATIC_CLI_TELEMETRY_OPTOUT?: string;
78
};
89
/**
910
*
@@ -12,7 +13,7 @@ export type AuthInfo = {
1213
*/
1314
export async function getAuthInfo(configDir: string): Promise<AuthInfo | null> {
1415
try {
15-
const data: AuthInfo | null = JSON.parse(await fsExtra.readFile(path.join(configDir, "config.json"), "utf8"));
16+
const data: AuthInfo | null = JSON.parse(await fs.readFile(path.join(configDir, "config.json"), "utf8"));
1617
return data;
1718
} catch (e) {
1819
return null;
@@ -21,14 +22,38 @@ export async function getAuthInfo(configDir: string): Promise<AuthInfo | null> {
2122

2223
/**
2324
*
24-
* @param {AuthInfo} credentials
25+
* @param {string} email
26+
* @param {string} authKey
27+
* @param {string} isTelemetryOptedOut
2528
* @param {string} configDir <- Directory with user configuration
2629
* //Function to set credentials.
2730
*/
28-
export async function setAuthInfo(credentials: AuthInfo, configDir: string): Promise<void> {
31+
export async function setAuthInfo(
32+
email: string,
33+
authKey: string,
34+
isTelemetryOptedOut: boolean,
35+
configDir: string
36+
): Promise<void> {
37+
const credentials: AuthInfo = {
38+
email,
39+
authKey,
40+
APIMATIC_CLI_TELEMETRY_OPTOUT: isTelemetryOptedOut ? "1" : "0"
41+
};
2942
const configFilePath = path.join(configDir, "config.json");
3043

31-
if (!fsExtra.existsSync(configFilePath)) fsExtra.createFileSync(configFilePath);
44+
if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath);
3245

33-
return await fsExtra.writeFile(configFilePath, JSON.stringify(credentials));
46+
return await fs.writeFile(configFilePath, JSON.stringify(credentials));
3447
}
48+
49+
export async function removeAuthInfo(configDir: string): Promise<void> {
50+
const credentials: AuthInfo = {
51+
email: "",
52+
authKey: ""
53+
};
54+
const configFilePath = path.join(configDir, "config.json");
55+
56+
if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath);
57+
58+
return await fs.writeFile(configFilePath, JSON.stringify(credentials));
59+
}

src/client-utils/sdk-client.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import axios, { AxiosResponse } from "axios";
33

44
import { Client } from "@apimatic/sdk";
55
import { baseURL } from "../config/env.js";
6-
import { setAuthInfo, getAuthInfo, AuthInfo } from "./auth-manager.js";
6+
import { setAuthInfo, getAuthInfo, AuthInfo, removeAuthInfo } from "./auth-manager.js";
77
/**
88
* The Singleton class defines the `getInstance` method that lets clients access
99
* the unique singleton instance.
@@ -39,26 +39,16 @@ export class SDKClient {
3939
public async login(email: string, password: string, configDir: string): Promise<string> {
4040
const credentials: Credentials = { email, password };
4141
const authKey: string = await this.getAuthKey(credentials);
42-
setAuthInfo(
43-
{
44-
email,
45-
authKey
46-
},
47-
configDir
48-
);
42+
const isTelemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1";
4943

50-
return "✅ Logged in successfully as " + email;
44+
await setAuthInfo(email, authKey, isTelemetryOptedOut, configDir);
5145

46+
return "✅ Logged in successfully as " + email;
5247
}
5348

5449
public async logout(configDir: string): Promise<string> {
55-
setAuthInfo(
56-
{
57-
email: "",
58-
authKey: ""
59-
},
60-
configDir
61-
);
50+
await removeAuthInfo(configDir);
51+
6252
return "Logged out";
6353
}
6454

@@ -118,14 +108,12 @@ export class SDKClient {
118108
const authKey: string = response.data.EncryptedValue;
119109
return authKey;
120110
}
111+
121112
public setAuthKey = (authKey: string, configDir: string) => {
122-
setAuthInfo(
123-
{
124-
email: "",
125-
authKey
126-
},
127-
configDir
128-
);
113+
const isTelemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1";
114+
115+
setAuthInfo("", authKey, isTelemetryOptedOut, configDir);
116+
129117
return "Authentication key successfully set";
130118
};
131119
}

src/commands/portal/recipe/new.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Command, Flags } from "@oclif/core";
22
import { PortalRecipeAction } from "../../../actions/portal/recipe/new-recipe.js";
33
import { PortalRecipePrompts } from "../../../prompts/portal/recipe/new-recipe.js";
44
import { getMessageInRedColor } from "../../../utils/utils.js";
5+
import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js";
6+
import { RecipeCreationFailedEvent } from "../../../types/events/recipe-creation-failed.js";
57
import { DirectoryPath } from "../../../types/file/directoryPath.js";
68

79
const DEFAULT_WORKING_DIRECTORY = "./";
@@ -26,18 +28,18 @@ export default class PortalRecipeNew extends Command {
2628

2729
public async run(): Promise<void> {
2830
const { flags } = await this.parse(PortalRecipeNew);
31+
const telemetryService = new TelemetryService(this.config.configDir);
2932
const portalRecipeAction = new PortalRecipeAction();
3033
const portalRecipePrompts = new PortalRecipePrompts();
3134

3235
const workingDirectory = new DirectoryPath(flags.folder ?? DEFAULT_WORKING_DIRECTORY);
3336
const buildDirectory = flags.folder ? new DirectoryPath(flags.folder, "build") : workingDirectory.join("build");
3437

35-
const createRecipeResult = await portalRecipeAction.createRecipe(
36-
buildDirectory,
37-
this.config.configDir,
38-
flags.name
39-
);
38+
const createRecipeResult = await portalRecipeAction.createRecipe(buildDirectory, this.config.configDir, flags.name);
39+
40+
//TODO: Add a mapper for automatically mapping events to logger and telemetry service.
4041
if (createRecipeResult.isFailed()) {
42+
telemetryService.trackEvent(new RecipeCreationFailedEvent(createRecipeResult.error!, PortalRecipeNew.id, flags));
4143
portalRecipePrompts.logError(getMessageInRedColor(createRecipeResult.error!));
4244
}
4345
if (createRecipeResult.isCancelled()) {

src/commands/portal/toc/new.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { Command, Flags } from "@oclif/core";
1+
import { Command, Config, Flags } from "@oclif/core";
22
import { PortalNewTocAction } from "../../../actions/portal/toc/new-toc.js";
3+
import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js";
4+
import { TocCreationFailedEvent } from "../../../types/events/toc-creation-failed.js";
35
import { DirectoryPath } from "../../../types/file/directoryPath.js";
46

57
const DEFAULT_WORKING_DIRECTORY = "./";
@@ -43,15 +45,16 @@ https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-porta
4345
static examples = [
4446
`$ apimatic portal:toc:new --destination="./portal/content/"`,
4547
`$ apimatic portal:toc:new --folder="./my-project"`,
46-
`$ apimatic portal:toc:new --folder="./my-project" --destination="./portal/content/"`
48+
`$ apimatic portal:toc:new --folder="./my-project" --destination="./portal/content/"`
4749
];
4850

49-
constructor(argv: string[], config: any) {
51+
constructor(argv: string[], config: Config) {
5052
super(argv, config);
5153
}
5254

5355
async run(): Promise<void> {
5456
const { flags } = await this.parse(PortalTocNew);
57+
const telemetryService = new TelemetryService(this.config.configDir);
5558
const portalNewTocAction = new PortalNewTocAction();
5659

5760
const workingDirectory = new DirectoryPath(flags.folder ?? DEFAULT_WORKING_DIRECTORY);
@@ -72,7 +75,9 @@ https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-porta
7275
flags["expand-models"]
7376
);
7477

78+
//TODO: Add a mapper for automatically mapping events to logger and telemetry service.
7579
if (result.isFailed()) {
80+
telemetryService.trackEvent(new TocCreationFailedEvent(result.error!, PortalTocNew.id, flags));
7681
this.error(result.error!);
7782
}
7883
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import process from "process";
2+
import os from "os";
3+
import fs from "fs-extra";
4+
import { fileURLToPath } from "url";
5+
import { dirname, join } from "path";
6+
import { baseURL } from "../../config/env.js";
7+
import { DomainEvent } from "../../types/events/domain-event.js";
8+
import { AuthInfo } from "../../client-utils/auth-manager.js";
9+
10+
type TelemetryPayload = {
11+
payload: DomainEvent;
12+
timestamp: string;
13+
cliVersion: string;
14+
platform: string;
15+
releaseVersion: string;
16+
nodeVersion: string;
17+
userAgent: string;
18+
};
19+
20+
export class TelemetryService {
21+
private readonly url: string = `${baseURL}/api/telemetry/track`;
22+
private readonly configDirectory: string;
23+
private static cachedCliVersion: string | null = null;
24+
25+
constructor(configDirectory: string) {
26+
this.configDirectory = configDirectory;
27+
}
28+
29+
public async trackEvent<T extends DomainEvent>(event: T): Promise<void> {
30+
const authInfo = await this.getAuthInfo(this.configDirectory);
31+
const telemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1";
32+
33+
if (telemetryOptedOut || authInfo?.APIMATIC_CLI_TELEMETRY_OPTOUT === "1") {
34+
return;
35+
}
36+
37+
const payload: TelemetryPayload = {
38+
payload: event,
39+
timestamp: new Date().toISOString(),
40+
cliVersion: this.getCLIVersion(),
41+
platform: os.platform(),
42+
releaseVersion: os.release(),
43+
nodeVersion: process.version,
44+
userAgent: this.getUserAgent()
45+
};
46+
47+
// TODO: Replace with method of API Service.
48+
try {
49+
await fetch(this.url, {
50+
method: "POST",
51+
headers: {
52+
"Content-Type": "application/json",
53+
"Authorization": `X-Auth-Key ${authInfo?.authKey || ""}`,
54+
},
55+
body: JSON.stringify(payload)
56+
});
57+
} catch {
58+
// Ignore, fail silently.
59+
}
60+
}
61+
62+
private getCLIVersion(): string {
63+
if (TelemetryService.cachedCliVersion) {
64+
return TelemetryService.cachedCliVersion;
65+
}
66+
67+
try {
68+
const __filename = fileURLToPath(import.meta.url);
69+
const __dirname = dirname(__filename);
70+
const pkgPath = join(__dirname, "../../../package.json");
71+
const pkgJson = fs.readFileSync(pkgPath, "utf-8");
72+
const pkg = JSON.parse(pkgJson);
73+
const version = pkg.version || "unknown";
74+
TelemetryService.cachedCliVersion = version;
75+
return version;
76+
} catch {
77+
return "unknown";
78+
}
79+
}
80+
81+
private async getAuthInfo(configDirectory: string): Promise<AuthInfo | null> {
82+
try {
83+
const data: AuthInfo | null = JSON.parse(await fs.readFile(join(configDirectory, "config.json"), "utf8"));
84+
return data;
85+
} catch {
86+
return null;
87+
}
88+
}
89+
90+
private getUserAgent(): string {
91+
const osInfo = `${os.platform()} ${os.release()}`;
92+
const engine = "Node.js";
93+
const engineVersion = process.version;
94+
95+
return `APIMATIC CLI - [OS: ${osInfo}, Engine: ${engine}/${engineVersion}]`;
96+
}
97+
}

src/types/events/domain-event.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export abstract class DomainEvent {
2+
protected abstract readonly eventName: string;
3+
private readonly message: string;
4+
private readonly commandName: string;
5+
private readonly flags: string[];
6+
7+
constructor(message: string, commandName: string, flags: Record<string, unknown>) {
8+
this.message = message;
9+
this.commandName = commandName;
10+
this.flags = this.extractFlagKeys(flags);
11+
}
12+
13+
private extractFlagKeys(flags: Record<string, unknown>): string[] {
14+
return Object.keys(flags);
15+
}
16+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { DomainEvent } from "../../types/events/domain-event.js";
2+
3+
export class RecipeCreationFailedEvent extends DomainEvent {
4+
protected readonly eventName = RecipeCreationFailedEvent.name;
5+
6+
constructor(message: string, commandName: string, flags: Record<string, unknown>) {
7+
super(message, commandName, flags);
8+
}
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { DomainEvent } from "../../types/events/domain-event.js";
2+
3+
export class TocCreationFailedEvent extends DomainEvent {
4+
protected readonly eventName = TocCreationFailedEvent.name;
5+
6+
constructor(message: string, commandName: string, flags: Record<string, unknown>) {
7+
super(message, commandName, flags);
8+
}
9+
}

0 commit comments

Comments
 (0)