Skip to content

Commit 3170b27

Browse files
authored
Adds additional Crashlytics tools for debugging/analyzing crashes (#9020)
Adds the following tools that can be used to debug and fix app crashes. - crashlytics_add_note: Adds a Crashlytics note to the issue - crashlytics_delete_note: Deletes a Crashlytics note - crashlytics_list_notes: Retrieves the notes on a Crashlytics issue - crashlytics_list_top_devices: List the top crashing devices for the app or an issue - crashlytics_list_top_operating_systems: List the top crashing operating systems for the app or an issue - crashlytics_list_top_versions: List the top crashing versions for the app or an issue - crashlytics_update_issue: Marks an issue as closed or re-opened.
1 parent 1c60a05 commit 3170b27

32 files changed

+1133
-102
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Adds additional Crashlytics tools for debugging/analyzing crashes (#9020)

src/crashlytics/addNote.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { addNote } from "./addNote";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
describe("addNote", () => {
12+
const appId = "1:1234567890:android:abcdef1234567890";
13+
const requestProjectNumber = "1234567890";
14+
const issueId = "test-issue-id";
15+
const note = "This is a test note.";
16+
17+
afterEach(() => {
18+
nock.cleanAll();
19+
});
20+
21+
it("should resolve with the response body on success", async () => {
22+
const mockResponse = { name: "note1", body: note };
23+
24+
nock(crashlyticsApiOrigin())
25+
.post(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`, {
26+
body: note,
27+
})
28+
.reply(200, mockResponse);
29+
30+
const result = await addNote(appId, issueId, note);
31+
32+
expect(result).to.deep.equal(mockResponse);
33+
expect(nock.isDone()).to.be.true;
34+
});
35+
36+
it("should throw a FirebaseError if the API call fails", async () => {
37+
nock(crashlyticsApiOrigin())
38+
.post(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
39+
.reply(500, { error: "Internal Server Error" });
40+
41+
await expect(addNote(appId, issueId, note)).to.be.rejectedWith(
42+
FirebaseError,
43+
`Failed to add note to issue ${issueId} for app ${appId}.`,
44+
);
45+
});
46+
47+
it("should throw a FirebaseError if the appId is invalid", async () => {
48+
const invalidAppId = "invalid-app-id";
49+
50+
await expect(addNote(invalidAppId, issueId, note)).to.be.rejectedWith(
51+
FirebaseError,
52+
"Unable to get the projectId from the AppId.",
53+
);
54+
});
55+
});

src/crashlytics/addNote.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { logger } from "../logger";
2+
import { FirebaseError } from "../error";
3+
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";
4+
5+
type NoteRequest = {
6+
body: string;
7+
};
8+
9+
export async function addNote(appId: string, issueId: string, note: string): Promise<string> {
10+
const requestProjectNumber = parseProjectNumber(appId);
11+
logger.debug(
12+
`[mcp][crashlytics] addNote called with appId: ${appId}, issueId: ${issueId}, note: ${note}`,
13+
);
14+
try {
15+
const response = await CRASHLYTICS_API_CLIENT.request<NoteRequest, string>({
16+
method: "POST",
17+
headers: {
18+
"Content-Type": "application/json",
19+
},
20+
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`,
21+
body: { body: note },
22+
timeout: TIMEOUT,
23+
});
24+
25+
return response.body;
26+
} catch (err: any) {
27+
logger.debug(err.message);
28+
throw new FirebaseError(
29+
`Failed to add note to issue ${issueId} for app ${appId}. Error: ${err}.`,
30+
{ original: err },
31+
);
32+
}
33+
}

src/crashlytics/deleteNote.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { deleteNote } from "./deleteNote";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("deleteNote", () => {
13+
const appId = "1:1234567890:android:abcdef1234567890";
14+
const requestProjectNumber = "1234567890";
15+
const issueId = "test-issue-id";
16+
const noteId = "test-note-id";
17+
18+
afterEach(() => {
19+
nock.cleanAll();
20+
});
21+
22+
it("should resolve on success", async () => {
23+
nock(crashlyticsApiOrigin())
24+
.delete(
25+
`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
26+
)
27+
.reply(200, {});
28+
29+
const result = await deleteNote(appId, issueId, noteId);
30+
31+
expect(result).to.deep.equal(`Successfully deleted note ${noteId} from issue ${issueId}.`);
32+
expect(nock.isDone()).to.be.true;
33+
});
34+
35+
it("should throw a FirebaseError if the API call fails", async () => {
36+
nock(crashlyticsApiOrigin())
37+
.delete(
38+
`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
39+
)
40+
.reply(500, { error: "Internal Server Error" });
41+
42+
await expect(deleteNote(appId, issueId, noteId)).to.be.rejectedWith(
43+
FirebaseError,
44+
`Failed to delete note ${noteId} from issue ${issueId} for app ${appId}.`,
45+
);
46+
});
47+
48+
it("should throw a FirebaseError if the appId is invalid", async () => {
49+
const invalidAppId = "invalid-app-id";
50+
51+
await expect(deleteNote(invalidAppId, issueId, noteId)).to.be.rejectedWith(
52+
FirebaseError,
53+
"Unable to get the projectId from the AppId.",
54+
);
55+
});
56+
});

src/crashlytics/deleteNote.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { logger } from "../logger";
2+
import { FirebaseError } from "../error";
3+
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";
4+
5+
export async function deleteNote(appId: string, issueId: string, noteId: string): Promise<string> {
6+
const requestProjectNumber = parseProjectNumber(appId);
7+
8+
logger.debug(
9+
`[mcp][crashlytics] deleteNote called with appId: ${appId}, issueId: ${issueId}, noteId: ${noteId}`,
10+
);
11+
try {
12+
await CRASHLYTICS_API_CLIENT.request<void, void>({
13+
method: "DELETE",
14+
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`,
15+
timeout: TIMEOUT,
16+
});
17+
return `Successfully deleted note ${noteId} from issue ${issueId}.`;
18+
} catch (err: any) {
19+
logger.debug(err.message);
20+
throw new FirebaseError(
21+
`Failed to delete note ${noteId} from issue ${issueId} for app ${appId}. Error: ${err}.`,
22+
{ original: err },
23+
);
24+
}
25+
}

src/crashlytics/getIssueDetails.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
1-
import { Client } from "../apiv2";
21
import { logger } from "../logger";
32
import { FirebaseError } from "../error";
4-
import { crashlyticsApiOrigin } from "../api";
5-
6-
const TIMEOUT = 10000;
7-
8-
const apiClient = new Client({
9-
urlPrefix: crashlyticsApiOrigin(),
10-
apiVersion: "v1alpha",
11-
});
3+
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";
124

135
export async function getIssueDetails(appId: string, issueId: string): Promise<string> {
14-
try {
15-
const requestProjectNumber = parseProjectNumber(appId);
16-
if (requestProjectNumber === undefined) {
17-
throw new FirebaseError("Unable to get the projectId from the AppId.");
18-
}
6+
const requestProjectNumber = parseProjectNumber(appId);
197

20-
const response = await apiClient.request<void, string>({
8+
logger.debug(
9+
`[mcp][crashlytics] getIssueDetails called with appId: ${appId}, issueId: ${issueId}`,
10+
);
11+
try {
12+
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
2113
method: "GET",
2214
headers: {
2315
"Content-Type": "application/json",
@@ -35,11 +27,3 @@ export async function getIssueDetails(appId: string, issueId: string): Promise<s
3527
);
3628
}
3729
}
38-
39-
function parseProjectNumber(appId: string): string | undefined {
40-
const appIdParts = appId.split(":");
41-
if (appIdParts.length > 1) {
42-
return appIdParts[1];
43-
}
44-
return undefined;
45-
}

src/crashlytics/getSampleCrash.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1-
import { Client } from "../apiv2";
21
import { logger } from "../logger";
32
import { FirebaseError } from "../error";
4-
import { crashlyticsApiOrigin } from "../api";
5-
6-
const TIMEOUT = 10000;
7-
8-
const apiClient = new Client({
9-
urlPrefix: crashlyticsApiOrigin(),
10-
apiVersion: "v1alpha",
11-
});
3+
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";
124

135
export async function getSampleCrash(
146
appId: string,
157
issueId: string,
168
sampleCount: number,
179
variantId?: string,
1810
): Promise<string> {
11+
const requestProjectNumber = parseProjectNumber(appId);
12+
13+
logger.debug(
14+
`[mcp][crashlytics] getSampleCrash called with appId: ${appId}, issueId: ${issueId}, sampleCount: ${sampleCount}, variantId: ${variantId}`,
15+
);
1916
try {
2017
const queryParams = new URLSearchParams();
2118
queryParams.set("filter.issue.id", issueId);
@@ -24,12 +21,8 @@ export async function getSampleCrash(
2421
queryParams.set("filter.issue.variant_id", variantId);
2522
}
2623

27-
const requestProjectNumber = parseProjectNumber(appId);
28-
if (requestProjectNumber === undefined) {
29-
throw new FirebaseError("Unable to get the projectId from the AppId.");
30-
}
31-
32-
const response = await apiClient.request<void, string>({
24+
logger.debug(`[mcp][crashlytics] getSampleCrash query paramaters: ${queryParams}`);
25+
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
3326
method: "GET",
3427
headers: {
3528
"Content-Type": "application/json",
@@ -48,11 +41,3 @@ export async function getSampleCrash(
4841
);
4942
}
5043
}
51-
52-
function parseProjectNumber(appId: string): string | undefined {
53-
const appIdParts = appId.split(":");
54-
if (appIdParts.length > 1) {
55-
return appIdParts[1];
56-
}
57-
return undefined;
58-
}

src/crashlytics/listNotes.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { listNotes } from "./listNotes";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("listNotes", () => {
13+
const appId = "1:1234567890:android:abcdef1234567890";
14+
const requestProjectNumber = "1234567890";
15+
const issueId = "test-issue-id";
16+
17+
afterEach(() => {
18+
nock.cleanAll();
19+
});
20+
21+
it("should resolve with the response body on success", async () => {
22+
const mockResponse = { notes: [{ name: "note1", body: "a note" }] };
23+
const noteCount = 10;
24+
25+
nock(crashlyticsApiOrigin())
26+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
27+
.query({
28+
page_size: `${noteCount}`,
29+
})
30+
.reply(200, mockResponse);
31+
32+
const result = await listNotes(appId, issueId, noteCount);
33+
34+
expect(result).to.deep.equal(mockResponse);
35+
expect(nock.isDone()).to.be.true;
36+
});
37+
38+
it("should throw a FirebaseError if the API call fails", async () => {
39+
const noteCount = 10;
40+
41+
nock(crashlyticsApiOrigin())
42+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`)
43+
.query({
44+
page_size: `${noteCount}`,
45+
})
46+
.reply(500, { error: "Internal Server Error" });
47+
48+
await expect(listNotes(appId, issueId, noteCount)).to.be.rejectedWith(
49+
FirebaseError,
50+
`Failed to fetch notes for issue ${issueId} for app ${appId}.`,
51+
);
52+
});
53+
54+
it("should throw a FirebaseError if the appId is invalid", async () => {
55+
const invalidAppId = "invalid-app-id";
56+
const noteCount = 10;
57+
58+
await expect(listNotes(invalidAppId, issueId, noteCount)).to.be.rejectedWith(
59+
FirebaseError,
60+
"Unable to get the projectId from the AppId.",
61+
);
62+
});
63+
});

src/crashlytics/listNotes.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { logger } from "../logger";
2+
import { FirebaseError } from "../error";
3+
import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils";
4+
5+
export async function listNotes(
6+
appId: string,
7+
issueId: string,
8+
noteCount: number,
9+
): Promise<string> {
10+
const requestProjectNumber = parseProjectNumber(appId);
11+
try {
12+
const queryParams = new URLSearchParams();
13+
queryParams.set("page_size", `${noteCount}`);
14+
15+
logger.debug(
16+
`[mcp][crashlytics] listNotes called with appId: ${appId}, issueId: ${issueId}, noteCount: ${noteCount}`,
17+
);
18+
const response = await CRASHLYTICS_API_CLIENT.request<void, string>({
19+
method: "GET",
20+
headers: {
21+
"Content-Type": "application/json",
22+
},
23+
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes`,
24+
queryParams: queryParams,
25+
timeout: TIMEOUT,
26+
});
27+
28+
return response.body;
29+
} catch (err: any) {
30+
logger.debug(err.message);
31+
throw new FirebaseError(
32+
`Failed to fetch notes for issue ${issueId} for app ${appId}. Error: ${err}.`,
33+
{ original: err },
34+
);
35+
}
36+
}

0 commit comments

Comments
 (0)