Skip to content

Commit e2e4e72

Browse files
committed
ci: Capture overhead in node app
1 parent 1854214 commit e2e4e72

17 files changed

+938
-16
lines changed

.github/workflows/build.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ jobs:
200200
changed_node:
201201
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
202202
'@sentry/node') }}
203+
changed_node_overhead_action:
204+
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
205+
'@sentry-internal/node-overhead-gh-action') }}
203206
changed_deno:
204207
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
205208
'@sentry/deno') }}
@@ -253,6 +256,37 @@ jobs:
253256
# Only run comparison against develop if this is a PR
254257
comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}}
255258

259+
job_node_overhead_check:
260+
name: Node Overhead Check
261+
needs: [job_get_metadata, job_build]
262+
timeout-minutes: 15
263+
runs-on: ubuntu-24.04
264+
if:
265+
(needs.job_build.outputs.changed_node == 'true' && github.event_name == 'pull_request') ||
266+
(needs.job_build.outputs.changed_node_overhead_action == 'true' && github.event_name == 'pull_request') ||
267+
needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true'
268+
steps:
269+
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
270+
uses: actions/checkout@v4
271+
with:
272+
ref: ${{ env.HEAD_COMMIT }}
273+
- name: Set up Node
274+
uses: actions/setup-node@v4
275+
with:
276+
node-version-file: 'package.json'
277+
- name: Restore caches
278+
uses: ./.github/actions/restore-cache
279+
with:
280+
dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
281+
- name: Check node overhead
282+
uses: ./dev-packages/node-overhead-gh-action
283+
env:
284+
DEBUG: '1'
285+
with:
286+
github_token: ${{ secrets.GITHUB_TOKEN }}
287+
# Only run comparison against develop if this is a PR
288+
comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}}
289+
256290
job_lint:
257291
name: Lint
258292
# Even though the linter only checks source code, not built code, it needs the built code in order check that all
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module.exports = {
2+
env: {
3+
node: true,
4+
},
5+
extends: ['../../.eslintrc.js'],
6+
overrides: [
7+
{
8+
files: ['**/*.mjs'],
9+
parserOptions: {
10+
project: ['tsconfig.json'],
11+
sourceType: 'module',
12+
},
13+
},
14+
],
15+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# node-overhead-gh-action
2+
3+
Capture the overhead of Sentry in a node app.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: 'node-overhead-gh-action'
2+
description: 'Run node overhead comparison'
3+
inputs:
4+
github_token:
5+
required: true
6+
description: 'a github access token'
7+
comparison_branch:
8+
required: false
9+
default: ''
10+
description: 'If set, compare the current branch with this branch'
11+
threshold:
12+
required: false
13+
default: '3'
14+
description: 'The percentage threshold for size changes before posting a comment'
15+
runs:
16+
using: 'node24'
17+
main: 'index.mjs'
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { DefaultArtifactClient } from '@actions/artifact';
5+
import * as core from '@actions/core';
6+
import { exec } from '@actions/exec';
7+
import { context, getOctokit } from '@actions/github';
8+
import * as glob from '@actions/glob';
9+
import * as io from '@actions/io';
10+
import { markdownTable } from 'markdown-table';
11+
import { getArtifactsForBranchAndWorkflow } from './lib/getArtifactsForBranchAndWorkflow.mjs';
12+
import { getOverheadMeasurements } from './lib/getOverheadMeasurements.mjs';
13+
import { formatResults, hasChanges } from './lib/markdown-table-formatter.mjs';
14+
15+
const NODE_OVERHEAD_HEADING = '## node-overhead report 🧳';
16+
const ARTIFACT_NAME = 'node-overhead-action';
17+
const RESULTS_FILE = 'node-overhead-results.json';
18+
19+
function getResultsFilePath() {
20+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
21+
return path.resolve(__dirname, RESULTS_FILE);
22+
}
23+
24+
const { getInput, setFailed } = core;
25+
26+
async function fetchPreviousComment(octokit, repo, pr) {
27+
const { data: commentList } = await octokit.rest.issues.listComments({
28+
...repo,
29+
issue_number: pr.number,
30+
});
31+
32+
const sizeLimitComment = commentList.find(comment => comment.body.startsWith(NODE_OVERHEAD_HEADING));
33+
return !sizeLimitComment ? null : sizeLimitComment;
34+
}
35+
36+
async function run() {
37+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
38+
39+
try {
40+
const { payload, repo } = context;
41+
const pr = payload.pull_request;
42+
43+
const comparisonBranch = getInput('comparison_branch');
44+
const githubToken = getInput('github_token');
45+
const threshold = getInput('threshold') || 1;
46+
47+
if (comparisonBranch && !pr) {
48+
throw new Error('No PR found. Only pull_request workflows are supported.');
49+
}
50+
51+
const octokit = getOctokit(githubToken);
52+
const resultsFilePath = getResultsFilePath();
53+
54+
// If we have no comparison branch, we just run size limit & store the result as artifact
55+
if (!comparisonBranch) {
56+
return runNodeOverheadOnComparisonBranch();
57+
}
58+
59+
// Else, we run size limit for the current branch, AND fetch it for the comparison branch
60+
let base;
61+
let current;
62+
let baseIsNotLatest = false;
63+
let baseWorkflowRun;
64+
65+
try {
66+
const workflowName = `${process.env.GITHUB_WORKFLOW || ''}`;
67+
core.startGroup(`getArtifactsForBranchAndWorkflow - workflow:"${workflowName}", branch:"${comparisonBranch}"`);
68+
const artifacts = await getArtifactsForBranchAndWorkflow(octokit, {
69+
...repo,
70+
artifactName: ARTIFACT_NAME,
71+
branch: comparisonBranch,
72+
workflowName,
73+
});
74+
core.endGroup();
75+
76+
if (!artifacts) {
77+
throw new Error('No artifacts found');
78+
}
79+
80+
baseWorkflowRun = artifacts.workflowRun;
81+
82+
await downloadOtherWorkflowArtifact(octokit, {
83+
...repo,
84+
artifactName: ARTIFACT_NAME,
85+
artifactId: artifacts.artifact.id,
86+
downloadPath: __dirname,
87+
});
88+
89+
base = JSON.parse(await fs.readFile(resultsFilePath, { encoding: 'utf8' }));
90+
91+
if (!artifacts.isLatest) {
92+
baseIsNotLatest = true;
93+
core.info('Base artifact is not the latest one. This may lead to incorrect results.');
94+
}
95+
} catch (error) {
96+
core.startGroup('Warning, unable to find base results');
97+
core.error(error);
98+
core.endGroup();
99+
}
100+
101+
core.startGroup('Getting current overhead measurements');
102+
try {
103+
current = await getOverheadMeasurements();
104+
} catch (error) {
105+
core.error('Error getting current overhead measurements');
106+
core.endGroup();
107+
throw error;
108+
}
109+
core.debug(`Current overhead measurements: ${JSON.stringify(current, null, 2)}`);
110+
core.endGroup();
111+
112+
const thresholdNumber = Number(threshold);
113+
114+
const nodeOverheadComment = await fetchPreviousComment(octokit, repo, pr);
115+
116+
if (nodeOverheadComment) {
117+
core.debug('Found existing node overhead comment, updating it instead of creating a new one...');
118+
}
119+
120+
const shouldComment = isNaN(thresholdNumber) || hasChanges(base, current, thresholdNumber) || nodeOverheadComment;
121+
122+
if (shouldComment) {
123+
const bodyParts = [
124+
NODE_OVERHEAD_HEADING,
125+
'Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.',
126+
];
127+
128+
if (baseIsNotLatest) {
129+
bodyParts.push(
130+
'⚠️ **Warning:** Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.',
131+
);
132+
}
133+
try {
134+
bodyParts.push(markdownTable(formatResults(base, current)));
135+
} catch (error) {
136+
core.error('Error generating markdown table');
137+
throw error;
138+
}
139+
140+
if (baseWorkflowRun) {
141+
bodyParts.push('');
142+
bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`);
143+
}
144+
145+
const body = bodyParts.join('\r\n');
146+
147+
try {
148+
if (!nodeOverheadComment) {
149+
await octokit.rest.issues.createComment({
150+
...repo,
151+
issue_number: pr.number,
152+
body,
153+
});
154+
} else {
155+
await octokit.rest.issues.updateComment({
156+
...repo,
157+
comment_id: nodeOverheadComment.id,
158+
body,
159+
});
160+
}
161+
} catch (error) {
162+
core.error(
163+
"Error updating comment. This can happen for PR's originating from a fork without write permissions.",
164+
);
165+
}
166+
} else {
167+
core.debug('Skipping comment because there are no changes.');
168+
}
169+
} catch (error) {
170+
core.error(error);
171+
setFailed(error.message);
172+
}
173+
}
174+
175+
async function runNodeOverheadOnComparisonBranch() {
176+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
177+
const resultsFilePath = getResultsFilePath();
178+
179+
const artifactClient = new DefaultArtifactClient();
180+
181+
const result = await getOverheadMeasurements();
182+
183+
try {
184+
await fs.writeFile(resultsFilePath, JSON.stringify(result), 'utf8');
185+
} catch (error) {
186+
core.error('Error parsing node overhead output. The output should be a json.');
187+
throw error;
188+
}
189+
190+
const globber = await glob.create(resultsFilePath, {
191+
followSymbolicLinks: false,
192+
});
193+
const files = await globber.glob();
194+
195+
await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname);
196+
}
197+
198+
run();
199+
200+
/**
201+
* Use GitHub API to fetch artifact download url, then
202+
* download and extract artifact to `downloadPath`
203+
*/
204+
async function downloadOtherWorkflowArtifact(octokit, { owner, repo, artifactId, artifactName, downloadPath }) {
205+
const artifact = await octokit.rest.actions.downloadArtifact({
206+
owner,
207+
repo,
208+
artifact_id: artifactId,
209+
archive_format: 'zip',
210+
});
211+
212+
// Make sure output path exists
213+
try {
214+
await io.mkdirP(downloadPath);
215+
} catch {
216+
// ignore errors
217+
}
218+
219+
const downloadFile = path.resolve(downloadPath, `${artifactName}.zip`);
220+
221+
await exec('wget', [
222+
'-nv',
223+
'--retry-connrefused',
224+
'--waitretry=1',
225+
'--read-timeout=20',
226+
'--timeout=15',
227+
'-t',
228+
'0',
229+
'-O',
230+
downloadFile,
231+
artifact.url,
232+
]);
233+
234+
await exec('unzip', ['-q', '-d', downloadPath, downloadFile], {
235+
silent: true,
236+
});
237+
}

0 commit comments

Comments
 (0)