Skip to content

Commit 85c2224

Browse files
authored
perf: improve Windows CLI performance (#3610)
* perf: optimize Windows startup time by skipping telemetry by default Improves Windows CLI startup performance by skipping telemetry loading: - Skip telemetry module loading on Windows unless ENABLE_WINDOWS_TELEMETRY=true - Use dynamic imports for telemetry in all hooks to avoid loading on Windows - Add performance tracking support via DEBUG=oclif:perf or DEBUG=* Changes: - bin/run: Skip telemetry on Windows, enable performance tracking - All analytics hooks: Use dynamic imports and check ENABLE_WINDOWS_TELEMETRY Performance impact: - Reduces Windows startup from ~8s to ~6s (testing showed further improvements expected) - Eliminates loading of OpenTelemetry/Sentry modules on Windows - No impact on non-Windows platforms Testing: - Set ENABLE_WINDOWS_TELEMETRY=true on Windows to enable telemetry when needed - Use DEBUG=oclif:perf or DEBUG=* to see detailed performance metrics * perf: implement lazy initialization in global_telemetry.ts Refactor telemetry initialization to eliminate blocking module load and reduce Windows performance impact. Key changes: - Remove top-level await that blocks module loading on every command - Defer Sentry.init(), registerInstrumentations(), and OTEL setup until first use via ensureInitialized() pattern - Eliminate package.json file read by using config.version from oclif Config object (already loaded during CLI bootstrap) - Add early return in initializeInstrumentation() when DISABLE_TELEMETRY=true - Add getProcessor() getter for backward compatibility Performance impact: - Removes ~1-2ms of blocking I/O on macOS - Expected to save 500ms-2s on Windows where file I/O is 2-3x slower and antivirus scanning impacts every file read - Combined with dynamic imports, significantly reduces Windows startup time Technical details: - config.version = config.options.version || config.pjson.version || '0.0.0' - setupTelemetry() stores config.version on first call (during init hook) - ensureInitialized() called from initializeInstrumentation(), sendToHoneycomb(), and sendToSentry() to ensure setup happens before use - All initialization respects isTelemetryDisabled flag * perf: avoid redundant package.json read in init/version hook Use already-loaded config.pjson instead of reading package.json from disk again via packageParser. Changes: - Remove import of getAllVersionFlags() from packageParser - Get version flags directly from this.config.pjson.oclif.additionalVersionFlags - Build the flags list in memory instead of triggering a file read Performance impact: - Eliminates 654ms file read on Windows (seen in performance testing) - No disk I/O since oclif already loaded package.json into config.pjson - Hook functionality remains identical * chore: remove unused packageParser.ts Remove packageParser.ts as it's no longer used after optimizing the init/version hook to use config.pjson directly. The file was only imported by init/version hook which now gets version flags from this.config.pjson.oclif.additionalVersionFlags instead of reading package.json from disk. * deps: update @heroku/heroku-cli-util to 10.7.0 with granular imports Update @heroku/heroku-cli-util from 10.6.1 to 10.7.0 which introduces granular import paths for better tree-shaking and module loading performance. Changes: - Update package.json dependency to ^10.7.0 - Migrate all imports from barrel exports to granular paths: - import {color} from '@heroku/heroku-cli-util' → import * as color from '@heroku/heroku-cli-util/color' - Similar changes for prompt, confirm, action, table, etc. This change improves module loading performance by allowing bundlers to load only the specific modules needed rather than the entire package. * linting fixes * perf: make SIGINT/SIGTERM telemetry fire-and-forget Convert signal handlers from async/await to fire-and-forget to improve exit responsiveness. Changes: - Remove async from SIGINT/SIGTERM handlers - Remove await before sendTelemetry() calls - Add .catch(() => {}) to suppress unhandled promise rejections - Call process.exit(1) immediately without blocking Benefits: - Immediate exit when user hits Ctrl+C (no blocking wait) - Better UX - responsive termination behavior - Telemetry send initiated but doesn't delay exit - Normal command completions still captured via beforeExit handler * switch to modular imports for some hooks files * perf: implement lazy initialization in colorize.ts Move color pre-assignment from module load time to first use via ensureInitialized(). Changes: - Add ensureInitialized() function to lazily assign colors - Call ensureInitialized() in getColorForIdentifier() before any color lookups - Remove module-load-time side effects (pre-assignment calls) Benefits: - Eliminates side effects at import time - Same behavior - common identifiers still get consistent colors - Defers work until actually needed - Better for module loading performance * refactor: improve Windows performance - Rename bin/run to bin/run.js with symlink for compatibility - Add --no-deprecation flag to suppress Node.js warnings - Update bin/run.cmd to reference run.js directly - Change terms-of-service hook to use async file I/O instead of sync * remove spaces test. we don't want to keep spaces around just for smoke tests. * restore intended display order for pipelines dif
1 parent 84eb648 commit 85c2224

File tree

261 files changed

+1963
-1825
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

261 files changed

+1963
-1825
lines changed

bin/run

Lines changed: 0 additions & 47 deletions
This file was deleted.

bin/run

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
run.js

bin/run.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
@echo off
22

3-
node "%~dp0\run" %*
3+
node "%~dp0\run.js" %*

bin/run.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env -S node --no-deprecation
2+
/* eslint-disable n/no-process-exit */
3+
/* eslint-disable n/no-unpublished-bin */
4+
5+
import {execute, settings} from '@oclif/core'
6+
7+
// Enable performance tracking when DEBUG=oclif:perf or DEBUG=* is set
8+
if (process.env.DEBUG?.includes('oclif:perf') || process.env.DEBUG === '*') {
9+
settings.performanceEnabled = true
10+
}
11+
12+
process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS || 'update with: "npm update -g heroku"'
13+
14+
const now = new Date()
15+
const cliStartTime = now.getTime()
16+
17+
// Skip telemetry entirely on Windows for performance (unless explicitly enabled)
18+
const enableTelemetry = process.platform !== 'win32' || process.env.ENABLE_WINDOWS_TELEMETRY === 'true'
19+
let globalTelemetry
20+
21+
if (enableTelemetry) {
22+
// Dynamically import telemetry only when needed
23+
globalTelemetry = await import('../dist/global_telemetry.js')
24+
}
25+
26+
process.once('beforeExit', async code => {
27+
if (!enableTelemetry) return
28+
29+
// capture as successful exit
30+
if (global.cliTelemetry) {
31+
if (global.cliTelemetry.isVersionOrHelp) {
32+
const cmdStartTime = global.cliTelemetry.commandRunDuration
33+
global.cliTelemetry.commandRunDuration = globalTelemetry.computeDuration(cmdStartTime)
34+
}
35+
36+
global.cliTelemetry.exitCode = code
37+
global.cliTelemetry.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
38+
const telemetryData = global.cliTelemetry
39+
await globalTelemetry.sendTelemetry(telemetryData)
40+
}
41+
})
42+
43+
process.on('SIGINT', () => {
44+
if (enableTelemetry) {
45+
// Fire-and-forget: attempt to send telemetry but don't block exit
46+
const error = new Error('Received SIGINT')
47+
error.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
48+
globalTelemetry.sendTelemetry(error).catch(() => {})
49+
}
50+
51+
process.exit(1)
52+
})
53+
54+
process.on('SIGTERM', () => {
55+
if (enableTelemetry) {
56+
// Fire-and-forget: attempt to send telemetry but don't block exit
57+
const error = new Error('Received SIGTERM')
58+
error.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
59+
globalTelemetry.sendTelemetry(error).catch(() => {})
60+
}
61+
62+
process.exit(1)
63+
})
64+
65+
if (enableTelemetry) {
66+
globalTelemetry.initializeInstrumentation()
67+
}
68+
69+
await execute({dir: import.meta.url})

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"@heroku-cli/notifications": "^1.2.6",
1111
"@heroku-cli/schema": "^1.0.25",
1212
"@heroku/buildpack-registry": "^1.0.1",
13-
"@heroku/heroku-cli-util": "^10.6.1",
13+
"@heroku/heroku-cli-util": "^10.7.0",
1414
"@heroku/http-call": "^5.5.1",
1515
"@heroku/mcp-server": "^1.2.0",
1616
"@heroku/socksv5": "^0.0.9",

scripts/postrelease/test_release

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ declare -a COMMANDS=(
5959
"$CMD_BIN run -x -a heroku-cli-test-staging -- 'bash -c \"exit 0\"'"
6060
"$CMD_BIN run:detached \"echo 'Hello World'\" -a heroku-cli-test-staging"
6161
"$CMD_BIN sessions"
62-
"$CMD_BIN spaces"
6362
"$CMD_BIN status"
6463
"$CMD_BIN teams"
6564
"$CMD_BIN version"

src/commands/access/add.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {color} from '@heroku/heroku-cli-util'
21
import {Command, flags} from '@heroku-cli/command'
32
import * as Heroku from '@heroku-cli/schema'
3+
import * as color from '@heroku/heroku-cli-util/color'
44
import {Args, ux} from '@oclif/core'
55

66
import {getOwner, isTeamApp} from '../../lib/teamUtils.js'

src/commands/access/index.ts

Lines changed: 50 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,65 @@
1-
import {color, hux} from '@heroku/heroku-cli-util'
21
import {Command, flags} from '@heroku-cli/command'
32
import {HerokuAPIError} from '@heroku-cli/command/lib/api-client.js'
43
import * as Heroku from '@heroku-cli/schema'
5-
import {ux} from '@oclif/core'
4+
import {color, hux} from '@heroku/heroku-cli-util'
5+
import {ux} from '@oclif/core/ux'
66
import _ from 'lodash'
77

88
import {getOwner, isTeamApp} from '../../lib/teamUtils.js'
99

10+
type AdminWithPermissions = Heroku.TeamMember & {
11+
permissions?: Heroku.TeamAppPermission[],
12+
}
13+
1014
type MemberData = {
1115
email: string,
1216
permissions?: string
1317
role: string,
1418
}
1519

16-
type AdminWithPermissions = {
17-
permissions?: Heroku.TeamAppPermission[],
18-
} & Heroku.TeamMember
20+
export default class AccessIndex extends Command {
21+
static description = 'list who has access to an app'
22+
static flags = {
23+
app: flags.app({required: true}),
24+
json: flags.boolean({description: 'output in json format'}),
25+
remote: flags.remote({char: 'r'}),
26+
}
1927

20-
function printJSON(collaborators: Heroku.TeamAppCollaborator[]) {
21-
ux.stdout(JSON.stringify(collaborators, null, 2))
28+
static topic = 'access'
29+
30+
public async run(): Promise<void> {
31+
const {flags} = await this.parse(AccessIndex)
32+
const {app: appName, json} = flags
33+
const {body: app} = await this.heroku.get<Heroku.App>(`/apps/${appName}`)
34+
let {body: collaborators} = await this.heroku.get<Heroku.TeamAppCollaborator[]>(`/apps/${appName}/collaborators`)
35+
if (isTeamApp(app.owner?.email)) {
36+
const teamName = getOwner(app.owner?.email)
37+
try {
38+
const {body: members} = await this.heroku.get<Heroku.TeamMember[]>(`/teams/${teamName}/members`)
39+
let admins: AdminWithPermissions[] = members.filter(member => member.role === 'admin')
40+
const {body: adminPermissions} = await this.heroku.get<Heroku.TeamAppPermission[]>('/teams/permissions')
41+
admins = _.forEach(admins, admin => {
42+
admin.user = {email: admin.email}
43+
admin.permissions = adminPermissions
44+
return admin
45+
})
46+
collaborators = buildCollaboratorsArray(collaborators, admins)
47+
} catch (error: any) {
48+
if (!(error instanceof HerokuAPIError && error.http.statusCode === 403))
49+
throw error
50+
}
51+
}
52+
53+
if (json)
54+
printJSON(collaborators)
55+
else
56+
printAccess(app, collaborators)
57+
}
58+
}
59+
60+
function buildCollaboratorsArray(collaboratorsRaw: Heroku.TeamAppCollaborator[], admins: Heroku.TeamMember[]) {
61+
const collaboratorsNoAdmins = _.reject(collaboratorsRaw, {role: 'admin'})
62+
return _.union(collaboratorsNoAdmins, admins)
2263
}
2364

2465
function buildTableColumns(showPermissions: boolean) {
@@ -65,47 +106,6 @@ function printAccess(app: Heroku.App, collaborators: any[]) {
65106
)
66107
}
67108

68-
function buildCollaboratorsArray(collaboratorsRaw: Heroku.TeamAppCollaborator[], admins: Heroku.TeamMember[]) {
69-
const collaboratorsNoAdmins = _.reject(collaboratorsRaw, {role: 'admin'})
70-
return _.union(collaboratorsNoAdmins, admins)
71-
}
72-
73-
export default class AccessIndex extends Command {
74-
static description = 'list who has access to an app'
75-
static flags = {
76-
app: flags.app({required: true}),
77-
json: flags.boolean({description: 'output in json format'}),
78-
remote: flags.remote({char: 'r'}),
79-
}
80-
81-
static topic = 'access'
82-
83-
public async run(): Promise<void> {
84-
const {flags} = await this.parse(AccessIndex)
85-
const {app: appName, json} = flags
86-
const {body: app} = await this.heroku.get<Heroku.App>(`/apps/${appName}`)
87-
let {body: collaborators} = await this.heroku.get<Heroku.TeamAppCollaborator[]>(`/apps/${appName}/collaborators`)
88-
if (isTeamApp(app.owner?.email)) {
89-
const teamName = getOwner(app.owner?.email)
90-
try {
91-
const {body: members} = await this.heroku.get<Heroku.TeamMember[]>(`/teams/${teamName}/members`)
92-
let admins: AdminWithPermissions[] = members.filter(member => member.role === 'admin')
93-
const {body: adminPermissions} = await this.heroku.get<Heroku.TeamAppPermission[]>('/teams/permissions')
94-
admins = _.forEach(admins, admin => {
95-
admin.user = {email: admin.email}
96-
admin.permissions = adminPermissions
97-
return admin
98-
})
99-
collaborators = buildCollaboratorsArray(collaborators, admins)
100-
} catch (error: any) {
101-
if (!(error instanceof HerokuAPIError && error.http.statusCode === 403))
102-
throw error
103-
}
104-
}
105-
106-
if (json)
107-
printJSON(collaborators)
108-
else
109-
printAccess(app, collaborators)
110-
}
109+
function printJSON(collaborators: Heroku.TeamAppCollaborator[]) {
110+
ux.stdout(JSON.stringify(collaborators, null, 2))
111111
}

src/commands/access/remove.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {color} from '@heroku/heroku-cli-util'
21
import {Command, flags} from '@heroku-cli/command'
32
import * as Heroku from '@heroku-cli/schema'
4-
import {ux} from '@oclif/core'
3+
import * as color from '@heroku/heroku-cli-util/color'
4+
import {ux} from '@oclif/core/ux'
55

66
export default class AccessRemove extends Command {
77
static description = 'remove users from a team app'

0 commit comments

Comments
 (0)