Skip to content

Commit 195ce7c

Browse files
authored
feat: send sentry bundle size metrics (#87)
1 parent 657d6cb commit 195ce7c

File tree

5 files changed

+267
-0
lines changed

5 files changed

+267
-0
lines changed

packages/bundler-plugin-core/src/bundle-analysis/bundleAnalysisPluginFactory.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getPreSignedURL } from "../utils/getPreSignedURL.ts";
99
import { type NormalizedOptions } from "../utils/normalizeOptions.ts";
1010
import { detectProvider } from "../utils/provider.ts";
1111
import { uploadStats } from "../utils/uploadStats.ts";
12+
import { sendSentryBundleStats } from "../utils/sentryUtils.ts";
1213

1314
interface BundleAnalysisUploadPluginArgs {
1415
options: NormalizedOptions;
@@ -50,6 +51,16 @@ export const bundleAnalysisPluginFactory = ({
5051
// don't need to do anything if the bundle name is not present or empty
5152
if (!options.bundleName || options.bundleName === "") return;
5253

54+
if (options.sentry?.isEnabled) {
55+
try {
56+
await sendSentryBundleStats(output, options);
57+
} catch {}
58+
59+
if (options?.sentry?.sentryOnly) {
60+
return;
61+
}
62+
}
63+
5364
const args: UploadOverrides = options.uploadOverrides ?? {};
5465
const envs = process.env;
5566
const inputs: ProviderUtilInputs = { envs, args };

packages/bundler-plugin-core/src/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,34 @@ export interface Options {
104104

105105
/** Override values for passing custom information to API. */
106106
uploadOverrides?: UploadOverrides;
107+
108+
sentry?: {
109+
/**
110+
* Only send bundle stats to sentry (used within sentry bundler plugin).
111+
*
112+
* Defaults to `false`
113+
*/
114+
sentryOnly?: boolean;
115+
116+
/**
117+
* Enables stats to be sent to sentry, set this to true on release.
118+
*
119+
* Defaults to `false`
120+
*/
121+
isEnabled?: boolean;
122+
123+
/** Sentry auth token to send bundle stats. */
124+
authToken?: string;
125+
126+
/** The name of the sentry organization to send bundler stats to. */
127+
org?: string;
128+
129+
/** The name of the sentry project to send bundler stats to. */
130+
project?: string;
131+
132+
/** The name of the sentry enviornment to send bundler stats to. */
133+
environment?: string;
134+
};
107135
}
108136

109137
export type BundleAnalysisUploadPlugin = (
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { type Options, type Output } from "../../../src";
2+
import { type SentryBundleStats, sendSentryBundleStats } from "../sentryUtils";
3+
4+
const fetchSpy: jest.SpyInstance = jest
5+
.spyOn(global, "fetch")
6+
.mockResolvedValue(new Response(undefined, { status: 200 }));
7+
8+
const mockOutput: Output = {
9+
bundleName: "my_bundle",
10+
assets: [
11+
{ name: "bundle.js", normalized: "bundle.js", size: 100 },
12+
{ name: "bundle_2.js", normalized: "bundle_2.js", size: 120 },
13+
{ name: "styles.css", normalized: "styles.css", size: 150 },
14+
],
15+
};
16+
17+
let mockOptions: Options;
18+
19+
describe("SentryUtils", () => {
20+
beforeEach(() => {
21+
mockOptions = {
22+
bundleName: "my_bundle",
23+
enableBundleAnalysis: true,
24+
sentry: {
25+
sentryOnly: true,
26+
environment: "test",
27+
org: "test-org",
28+
project: "test-project",
29+
authToken: "test-token",
30+
},
31+
};
32+
jest.clearAllMocks();
33+
});
34+
35+
it("should call sentry api with correct body if all options are provided", async () => {
36+
await sendSentryBundleStats(mockOutput, mockOptions);
37+
expect(fetchSpy).toHaveBeenCalledWith(
38+
"https://sentry.io/api/0/projects/test-org/test-project/bundle-stats/",
39+
{
40+
body: JSON.stringify({
41+
stats: [
42+
{
43+
total_size: 370,
44+
javascript_size: 220,
45+
css_size: 150,
46+
fonts_size: 0,
47+
images_size: 0,
48+
bundle_name: "my_bundle",
49+
environment: "test",
50+
} satisfies SentryBundleStats,
51+
],
52+
}),
53+
headers: {
54+
Authorization: "Bearer test-token",
55+
"Content-Type": "application/json",
56+
},
57+
method: "POST",
58+
},
59+
);
60+
});
61+
62+
it("should not call api if missing token", async () => {
63+
if (mockOptions.sentry) {
64+
mockOptions.sentry.authToken = undefined;
65+
}
66+
await sendSentryBundleStats(mockOutput, mockOptions);
67+
expect(fetchSpy).not.toHaveBeenCalled();
68+
});
69+
});

packages/bundler-plugin-core/src/utils/normalizeOptions.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,36 @@ const optionsSchemaFactory = (options: Options) =>
8080
.string({ invalid_type_error: "`uploadToken` must be a string." })
8181
.optional(),
8282
uploadOverrides: UploadOverridesSchema.optional(),
83+
sentry: z
84+
.object({
85+
sentryOnly: z
86+
.boolean({
87+
invalid_type_error: "`sentry.sentryOnly` must be a boolean.",
88+
})
89+
.default(false),
90+
authToken: z.string({
91+
invalid_type_error: "`sentry.authToken` must be a string.",
92+
}),
93+
isEnabled: z
94+
.boolean({
95+
invalid_type_error: "`sentry.isEnabled` must be a boolean.",
96+
})
97+
.default(false),
98+
org: z
99+
.string({ invalid_type_error: "`sentry.org` must be a string." })
100+
.optional(),
101+
project: z
102+
.string({
103+
invalid_type_error: "`sentry.project` must be a string.",
104+
})
105+
.optional(),
106+
environment: z
107+
.string({
108+
invalid_type_error: "`sentry.environment` must be a string.",
109+
})
110+
.optional(),
111+
})
112+
.optional(),
83113
});
84114

85115
interface NormalizedOptionsFailure {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { type Options, type Output } from "../../src";
2+
import { green, red } from "./logging";
3+
export const FONT_FILE_EXTENSIONS = ["woff", "woff2", "ttf", "otf", "eot"];
4+
export const IMAGE_FILE_EXTENSIONS = [
5+
"jpg",
6+
"jpeg",
7+
"png",
8+
"gif",
9+
"svg",
10+
"webp",
11+
"apng",
12+
"avif",
13+
];
14+
export interface SentryBundleStats {
15+
total_size: number;
16+
javascript_size: number;
17+
css_size: number;
18+
fonts_size: number;
19+
images_size: number;
20+
bundle_name: string;
21+
environment: string;
22+
}
23+
24+
export const sendSentryBundleStats = async (
25+
output: Output,
26+
userOptions: Options,
27+
) => {
28+
const {
29+
org: sentryOrganization,
30+
project: sentryProject,
31+
environment: sentryEnviornment,
32+
authToken: sentryAuthToken,
33+
} = userOptions?.sentry ?? {};
34+
35+
if (!sentryAuthToken) {
36+
red("Missing Sentry Auth Token");
37+
return;
38+
}
39+
40+
const { bundleName } = userOptions;
41+
if (
42+
!sentryOrganization ||
43+
!sentryProject ||
44+
!bundleName ||
45+
!sentryEnviornment
46+
) {
47+
red("Missing sentry org, project or bundle name");
48+
return;
49+
}
50+
51+
if (!output.assets) {
52+
red("We could not find any output assets from the stats file");
53+
return;
54+
}
55+
56+
const bundleStats: SentryBundleStats = {
57+
total_size: 0,
58+
javascript_size: 0,
59+
css_size: 0,
60+
fonts_size: 0,
61+
images_size: 0,
62+
bundle_name: bundleName,
63+
environment: sentryEnviornment,
64+
};
65+
66+
output.assets.forEach((asset) => {
67+
bundleStats.total_size += asset.size;
68+
const fileExtension = asset.name.split(".").pop();
69+
if (!fileExtension || fileExtension === asset.name) {
70+
return;
71+
}
72+
if (fileExtension === "js") {
73+
bundleStats.javascript_size += asset.size;
74+
} else if (fileExtension === "css") {
75+
bundleStats.css_size += asset.size;
76+
} else if (FONT_FILE_EXTENSIONS.includes(fileExtension)) {
77+
bundleStats.fonts_size += asset.size;
78+
} else if (IMAGE_FILE_EXTENSIONS.includes(fileExtension)) {
79+
bundleStats.images_size += asset.size;
80+
}
81+
});
82+
83+
await sendMetrics(
84+
[bundleStats],
85+
sentryOrganization,
86+
sentryProject,
87+
sentryAuthToken,
88+
);
89+
};
90+
91+
const sendMetrics = async (
92+
bundleStats: SentryBundleStats[],
93+
sentryOrganization: string,
94+
sentryProject: string,
95+
sentryAuthToken: string,
96+
) => {
97+
const res = await fetch(
98+
`https://sentry.io/api/0/projects/${sentryOrganization}/${sentryProject}/bundle-stats/`,
99+
{
100+
method: "POST",
101+
body: JSON.stringify({ stats: bundleStats }),
102+
headers: {
103+
Authorization: `Bearer ${sentryAuthToken}`,
104+
"Content-Type": "application/json",
105+
},
106+
},
107+
);
108+
109+
if (res.status === 200) {
110+
green("Sentry Metrics added!");
111+
const bundleStatsString: string = bundleStats
112+
.map(
113+
(bundle) => `
114+
${bundle.bundle_name}
115+
total bundle size: ${bundle.total_size}
116+
javascript size: ${bundle.javascript_size}
117+
css size: ${bundle.css_size}
118+
fonts size: ${bundle.fonts_size}
119+
images size: ${bundle.images_size}
120+
`,
121+
)
122+
.join("\n");
123+
124+
green(`The following stats were sent: \n${bundleStatsString}`);
125+
} else {
126+
const body = await res.json();
127+
red(`Failed to send metrics to do the error: \n ${body}`);
128+
}
129+
};

0 commit comments

Comments
 (0)