Skip to content

Commit c13074d

Browse files
authored
refactor: unify telemetry architecture and use background workers for all telemetry (#3642)
* refactor: unify telemetry architecture and use background workers for all telemetry This PR refactors the telemetry system to improve performance and consistency: 1. Unified background worker pattern: Both Herokulytics and OTEL/Sentry telemetry now use the same background worker process via spawnTelemetryWorker(), preventing any telemetry operations from blocking the main CLI process. 2. Consistent class-based architecture: All three telemetry clients (BackboardHerokulyticsClient, BackboardOtelClient, SentryClient) now use the same class-based pattern with lazy initialization. 3. Improved naming for clarity: - analytics.ts → backboard-herokulytics-client.ts (clarifies it sends Herokulytics data) - honeycomb-client.ts → backboard-otel-client.ts (clarifies it sends to Backboard, not directly to Honeycomb) - Hook files renamed to describe what they do (e.g., collect-and-send-herokulytics.ts) 4. Enhanced debugging: Added comprehensive telemetryDebug logging throughout all telemetry clients for easier troubleshooting (enabled via DEBUG=analytics-telemetry). 5. Optimized imports: Moved dynamic imports after telemetry checks to avoid loading unnecessary modules when telemetry is disabled. 6. Added isTTY to debug output: Fixed missing isTTY field in OTEL debug logging. Key architectural improvements: - Herokulytics (command usage analytics) → Backboard /hamurai endpoint - OTEL (performance telemetry) → Backboard /otel/v1/traces endpoint → Honeycomb - Sentry (error reporting) → Sentry.io - All three systems now use background workers, ensuring zero blocking overhead * refactor: add explicit type discriminators for telemetry data Replace implicit type inference based on field presence with explicit _type discriminators for all telemetry data types. This makes the telemetry worker more robust and self-documenting. Changes: - Add _type: 'herokulytics' to HerokulyticsData interface - Add _type: 'otel' to Telemetry interface - Add _type: 'error' to CLIError interface (optional) - Update telemetry-worker to check _type field instead of inferring from field presence - Update serializeTelemetryData to add _type: 'error' for Error objects - Fix OTEL provider to use module-level singleton instead of instance-level to avoid global registry conflicts in tests - Update all test mocks to include _type field * fix lint * fix: remove duplicate telemetry send from beforeExit handler The beforeExit handler in worker-client.ts was causing duplicate telemetry sends because the postrun hook also sends telemetry for successful command completion. This resulted in two spans being created for every command. The fix removes the beforeExit handler for normal command completion, while keeping the SIGINT/SIGTERM handlers since those bypass the hook lifecycle and still need to send telemetry. Now telemetry is sent once via the postrun hook for normal command completion, and via signal handlers only when the process is interrupted. * chore: customize telemetry debug namespace and color Changes the debug namespace from 'analytics-telemetry' to 'heroku:analytics' to match Heroku CLI conventions, and sets a custom color (147) for better visibility in terminal output. * perf: lazy-load telemetry clients to reduce init hook time Defers loading of heavy OpenTelemetry and Sentry libraries until they're actually needed (in the background worker process), rather than during CLI initialization. This should significantly reduce the setup-otel-telemetry init hook time. Before: OpenTelemetry/Sentry libraries loaded during init hook (~199ms) After: Libraries only loaded when sendTelemetry() is called in worker process * refactor: convert global-telemetry to singleton class pattern Refactors the telemetry module to use a singleton class pattern for better encapsulation: - Renamed global-telemetry.ts to telemetry-manager.ts for clarity - Converted standalone functions to TelemetryManager class methods - Exported singleton instance (telemetryManager) for use across codebase - Updated all imports in hooks and worker to use singleton instance - Maintained lazy-loading of OpenTelemetry/Sentry clients for performance This provides better organization and encapsulation of telemetry state while preserving all existing functionality. * perf: lazy-load heavy dependencies in init path Optimizes CLI startup time by deferring heavy module loads: 1. Lazy-load fs-extra in terms-of-service hook - Only loads when TOS banner needs to be shown (one-time per user) - Most users skip this import entirely 2. Lazy-load @heroku-cli/command and @oclif/core/config in getToken() - Made getToken() async with dynamic imports - These modules only needed in background worker, not during init - Updated BackboardOtelClient.ensureInitialized() to be async - Updated all call sites and tests These changes reduce the number of heavy modules loaded during CLI initialization, improving startup performance. * refactor: convert commands to use lazy-loaded modules Convert 27 commands to use centralized LazyModuleLoader for improved CLI startup performance: - Lodash (8 commands): dashboard, apps/info, access, config/edit, apps, releases, ps/type, ps/scale - Inquirer (8 commands): pipelines/create, pipelines/add, domains/add, apps/transfer, keys/add, certs/add, certs/generate, domains (using @inquirer/prompts) - Date-fns (5 commands): status, auth/token, certs/auto, data/maintenances/info, data/maintenances/schedule - Chrono (1 command): data/pg/fork - Yaml (1 command, from previous session): apps/create Changes defer heavy npm package imports until command execution time rather than at module parse time, reducing CLI load time. Skipped data/pg/create and data/pg/update due to complex wrapper prompt patterns. * refactor: remove deps.ts and rename UserConfig to HerokulyticsConfig - Delete src/deps.ts (unnecessary indirection layer) - Move user-config.ts to lib/analytics-telemetry/herokulytics-config.ts - Rename UserConfig class to HerokulyticsConfig (more descriptive) - Update backboard-herokulytics-client to import HTTP and HerokulyticsConfig directly - Update test files to use new import paths and class name - Fix fork.unit.test.ts to pass chrono parameter to parseRollbackInterval Since BackboardHerokulyticsClient is already lazy-loaded in the background telemetry worker, deps.ts provided no performance benefit. Direct imports are clearer and simpler. * feat: add 10 second timeout to telemetry worker process Add MAX_WORKER_LIFETIME_MS timeout to ensure the background telemetry worker never hangs indefinitely. This prevents the worker from running forever in case of: - Network request hangs - OpenTelemetry/Sentry failures - Other unexpected blocking operations The worker will now automatically exit after 10 seconds maximum, ensuring no orphaned background processes. * chore: remove unnecessary lazy-load comments Remove all 'Lazy-load' comments throughout the codebase. The code is self-documenting with the lazyModuleLoader pattern, making these comments redundant. * slightly improve version hook
1 parent 32920f9 commit c13074d

Some content is hidden

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

52 files changed

+1115
-869
lines changed

cspell-dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ hamurai
115115
herokai
116116
herokuapp
117117
herokudns
118+
herokulytics
118119
herokumanager
119120
herokussl
120121
histfile

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,21 +182,21 @@
182182
"dirname": "heroku",
183183
"hooks": {
184184
"command_not_found": [
185-
"./dist/hooks/command_not_found/performance_analytics"
185+
"./dist/hooks/command_not_found/setup-otel-telemetry"
186186
],
187187
"finally": [
188-
"./dist/hooks/finally/sentry"
188+
"./dist/hooks/finally/send-otel-and-sentry-errors"
189189
],
190190
"init": [
191191
"./dist/hooks/init/version",
192192
"./dist/hooks/init/terms-of-service",
193-
"./dist/hooks/init/performance_analytics"
193+
"./dist/hooks/init/setup-otel-telemetry"
194194
],
195195
"postrun": [
196-
"./dist/hooks/postrun/performance_analytics"
196+
"./dist/hooks/postrun/send-otel-telemetry"
197197
],
198198
"prerun": [
199-
"./dist/hooks/prerun/analytics"
199+
"./dist/hooks/prerun/collect-and-send-herokulytics"
200200
],
201201
"preupdate": [
202202
"./dist/hooks/preupdate/check-npm-auth"

src/commands/access/index.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {HerokuAPIError} from '@heroku-cli/command/lib/api-client.js'
33
import * as Heroku from '@heroku-cli/schema'
44
import {color, hux} from '@heroku/heroku-cli-util'
55
import {ux} from '@oclif/core/ux'
6-
import _ from 'lodash'
76

7+
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
88
import {getOwner, isTeamApp} from '../../lib/teamUtils.js'
99

1010
type AdminWithPermissions = Heroku.TeamMember & {
@@ -28,6 +28,8 @@ export default class AccessIndex extends Command {
2828
static topic = 'access'
2929

3030
public async run(): Promise<void> {
31+
const _ = await lazyModuleLoader.loadLodash()
32+
3133
const {flags} = await this.parse(AccessIndex)
3234
const {app: appName, json} = flags
3335
const {body: app} = await this.heroku.get<Heroku.App>(`/apps/${appName}`)
@@ -43,7 +45,7 @@ export default class AccessIndex extends Command {
4345
admin.permissions = adminPermissions
4446
return admin
4547
})
46-
collaborators = buildCollaboratorsArray(collaborators, admins)
48+
collaborators = buildCollaboratorsArray(collaborators, admins, _)
4749
} catch (error: any) {
4850
if (!(error instanceof HerokuAPIError && error.http.statusCode === 403))
4951
throw error
@@ -53,11 +55,11 @@ export default class AccessIndex extends Command {
5355
if (json)
5456
printJSON(collaborators)
5557
else
56-
printAccess(app, collaborators)
58+
printAccess(app, collaborators, _)
5759
}
5860
}
5961

60-
function buildCollaboratorsArray(collaboratorsRaw: Heroku.TeamAppCollaborator[], admins: Heroku.TeamMember[]) {
62+
function buildCollaboratorsArray(collaboratorsRaw: Heroku.TeamAppCollaborator[], admins: Heroku.TeamMember[], _: any) {
6163
const collaboratorsNoAdmins = _.reject(collaboratorsRaw, {role: 'admin'})
6264
return _.union(collaboratorsNoAdmins, admins)
6365
}
@@ -82,12 +84,12 @@ function buildTableColumns(showPermissions: boolean) {
8284
return baseColumns
8385
}
8486

85-
function printAccess(app: Heroku.App, collaborators: any[]) {
87+
function printAccess(app: Heroku.App, collaborators: any[], _: any) {
8688
const showPermissions = isTeamApp(app.owner?.email)
8789
collaborators = _.chain(collaborators)
88-
.sortBy(c => c.email || c.user.email)
89-
.reject(c => /herokumanager\.com$/.test(c.user.email))
90-
.map(collab => {
90+
.sortBy((c: any) => c.email || c.user.email)
91+
.reject((c: any) => /herokumanager\.com$/.test(c.user.email))
92+
.map((collab: any) => {
9193
const {email} = collab.user
9294
const {permissions, role} = collab
9395
const data: MemberData = {email, role: role || 'collaborator'}

src/commands/apps/create.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import {
99
import * as Heroku from '@heroku-cli/schema'
1010
import {Args, Interfaces, ux} from '@oclif/core'
1111
import fs from 'fs-extra'
12-
import {parse} from 'yaml'
1312

1413
import Git from '../../lib/git/git.js'
14+
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
1515

1616
const git = new Git()
1717

@@ -193,6 +193,7 @@ ${color.command('heroku apps:create --region eu')}`]
193193
static hiddenAliases = ['create']
194194

195195
async readManifest() {
196+
const {parse} = await lazyModuleLoader.loadYaml()
196197
const buffer = await fs.readFile('heroku.yml')
197198
return parse(buffer.toString())
198199
}

src/commands/apps/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {SpaceCompletion} from '@heroku-cli/command/lib/completions.js'
33
import * as Heroku from '@heroku-cli/schema'
44
import {color, hux} from '@heroku/heroku-cli-util'
55
import {ux} from '@oclif/core/ux'
6-
import _ from 'lodash'
76

7+
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
88
import {App} from '../../lib/types/app.js'
99

1010
export default class AppsIndex extends Command {
@@ -32,6 +32,8 @@ export default class AppsIndex extends Command {
3232
static topic = 'apps'
3333

3434
async run() {
35+
const _ = await lazyModuleLoader.loadLodash()
36+
3537
const {flags} = await this.parse(AppsIndex)
3638

3739
const teamIdentifier = flags.team
@@ -65,7 +67,7 @@ export default class AppsIndex extends Command {
6567
if (json) {
6668
hux.styledJSON(apps)
6769
} else {
68-
print(apps, user, space, team)
70+
print(apps, user, space, team, _)
6971
}
7072
}
7173
}
@@ -87,7 +89,7 @@ function listApps(apps: Heroku.App) {
8789
apps.forEach((app: App) => ux.stdout(regionizeAppName(app)))
8890
}
8991

90-
function print(apps: Heroku.App, user: Heroku.Account, space?: string, team?: null | string) {
92+
function print(apps: Heroku.App, user: Heroku.Account, space: string | undefined, team: null | string | undefined, _: any) {
9193
if (apps.length === 0) {
9294
if (space) ux.stdout(`There are no apps in space ${color.space(space)}.`)
9395
else if (team) ux.stdout(`There are no apps in team ${color.team(team)}.`)

src/commands/apps/info.ts

Lines changed: 87 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,96 @@
1-
import {color, hux} from '@heroku/heroku-cli-util'
21
import {Command, flags} from '@heroku-cli/command'
32
import * as Heroku from '@heroku-cli/schema'
3+
import {color, hux} from '@heroku/heroku-cli-util'
44
import {Args, ux} from '@oclif/core'
55
import {filesize} from 'filesize'
6-
import _ from 'lodash'
76
import * as util from 'util'
87

98
import {getGeneration} from '../../lib/apps/generation.js'
9+
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
10+
11+
export default class AppsInfo extends Command {
12+
static args = {
13+
app: Args.string({hidden: true}),
14+
}
15+
16+
static description = 'show detailed app information'
17+
static examples = [
18+
color.command('heroku apps:info'),
19+
color.command('heroku apps:info --shell'),
20+
]
21+
22+
static flags = {
23+
app: flags.app(),
24+
extended: flags.boolean({char: 'x', hidden: true}),
25+
json: flags.boolean({char: 'j', description: 'output in json format'}),
26+
remote: flags.remote(),
27+
shell: flags.boolean({char: 's', description: 'output more shell friendly key/value pairs'}),
28+
}
29+
30+
static help = `$ heroku apps:info
31+
=== example
32+
Git URL: https://git.heroku.com/example.git
33+
Repo Size: 5M
34+
...
35+
36+
$ heroku apps:info --shell
37+
git_url=https://git.heroku.com/example.git
38+
repo_size=5000000
39+
...`
40+
41+
static hiddenAliases = ['info']
42+
43+
static topic = 'apps'
44+
45+
async run() {
46+
const _ = await lazyModuleLoader.loadLodash()
47+
48+
const {args, flags} = await this.parse(AppsInfo)
1049

11-
const {countBy, snakeCase} = _
50+
const app = args.app || flags.app
51+
if (!app) throw new Error('No app specified.\nUSAGE: heroku apps:info --app my-app')
52+
53+
const info = await getInfo(app, this, flags.extended)
54+
const addons = info.addons.map((a: Heroku.AddOn) => a.plan?.name).sort()
55+
const collaborators = info.collaborators.map((c: Heroku.Collaborator) => c.user.email)
56+
.filter((c: Heroku.Collaborator) => c !== info.app.owner.email)
57+
.sort()
58+
59+
function shell() {
60+
function print(k: string, v: string) {
61+
ux.stdout(`${_.snakeCase(k)}=${v}`)
62+
}
63+
64+
print('auto_cert_mgmt', info.app.acm)
65+
print('addons', addons)
66+
print('collaborators', collaborators)
67+
68+
if (info.app.archived_at) print('archived_at', formatDate(new Date(info.app.archived_at)))
69+
if (info.app.cron_finished_at) print('cron_finished_at', formatDate(new Date(info.app.cron_finished_at)))
70+
if (info.app.cron_next_run) print('cron_next_run', formatDate(new Date(info.app.cron_next_run)))
71+
if (info.app.database_size) print('database_size', filesize(info.app.database_size, {round: 0, standard: 'jedec'}))
72+
if (info.app.create_status !== 'complete') print('create_status', info.app.create_status)
73+
if (info.pipeline_coupling) print('pipeline', `${info.pipeline_coupling.pipeline.name}:${info.pipeline_coupling.stage}`)
74+
75+
print('git_url', info.app.git_url)
76+
print('web_url', info.app.web_url)
77+
print('repo_size', filesize(info.app.repo_size, {round: 0, standard: 'jedec'}))
78+
if (getGeneration(info.app) !== 'fir') print('slug_size', filesize(info.app.slug_size, {round: 0, standard: 'jedec'}))
79+
print('owner', info.app.owner.email)
80+
print('region', info.app.region.name)
81+
print('dynos', util.inspect(_.countBy(info.dynos, 'type')))
82+
print('stack', info.app.stack.name)
83+
}
84+
85+
if (flags.shell) {
86+
shell()
87+
} else if (flags.json) {
88+
hux.styledJSON(info)
89+
} else {
90+
print(info, addons, collaborators, flags.extended, _)
91+
}
92+
}
93+
}
1294

1395
function formatDate(date: Date) {
1496
return date.toISOString()
@@ -57,7 +139,7 @@ async function getInfo(app: string, client: Command, extended: boolean) {
57139
return data
58140
}
59141

60-
function print(info: Heroku.App, addons: Heroku.AddOn[], collaborators: Heroku.Collaborator[], extended: boolean) {
142+
function print(info: Heroku.App, addons: Heroku.AddOn[], collaborators: Heroku.Collaborator[], extended: boolean, _: any) {
61143
const data: Heroku.App = {}
62144
data.Addons = addons
63145
data.Collaborators = collaborators
@@ -78,7 +160,7 @@ function print(info: Heroku.App, addons: Heroku.AddOn[], collaborators: Heroku.C
78160
if (getGeneration(info.app) !== 'fir') data['Slug Size'] = filesize(info.app.slug_size, {round: 0, standard: 'jedec'})
79161
data.Owner = color.user(info.app.owner.email)
80162
data.Region = info.app.region.name
81-
data.Dynos = countBy(info.dynos, 'type')
163+
data.Dynos = _.countBy(info.dynos, 'type')
82164
data.Stack = (function (app) {
83165
let stack = info.app.stack.name
84166
if (app.stack.name !== app.build_stack.name) {
@@ -98,85 +180,3 @@ function print(info: Heroku.App, addons: Heroku.AddOn[], collaborators: Heroku.C
98180
}
99181
}
100182
}
101-
102-
export default class AppsInfo extends Command {
103-
static args = {
104-
app: Args.string({hidden: true}),
105-
}
106-
107-
static description = 'show detailed app information'
108-
static examples = [
109-
color.command('heroku apps:info'),
110-
color.command('heroku apps:info --shell'),
111-
]
112-
113-
static flags = {
114-
app: flags.app(),
115-
extended: flags.boolean({char: 'x', hidden: true}),
116-
json: flags.boolean({char: 'j', description: 'output in json format'}),
117-
remote: flags.remote(),
118-
shell: flags.boolean({char: 's', description: 'output more shell friendly key/value pairs'}),
119-
}
120-
121-
static help = `$ heroku apps:info
122-
=== example
123-
Git URL: https://git.heroku.com/example.git
124-
Repo Size: 5M
125-
...
126-
127-
$ heroku apps:info --shell
128-
git_url=https://git.heroku.com/example.git
129-
repo_size=5000000
130-
...`
131-
132-
static hiddenAliases = ['info']
133-
134-
static topic = 'apps'
135-
136-
async run() {
137-
const {args, flags} = await this.parse(AppsInfo)
138-
139-
const app = args.app || flags.app
140-
if (!app) throw new Error('No app specified.\nUSAGE: heroku apps:info --app my-app')
141-
142-
const info = await getInfo(app, this, flags.extended)
143-
const addons = info.addons.map((a: Heroku.AddOn) => a.plan?.name).sort()
144-
const collaborators = info.collaborators.map((c: Heroku.Collaborator) => c.user.email)
145-
.filter((c: Heroku.Collaborator) => c !== info.app.owner.email)
146-
.sort()
147-
148-
function shell() {
149-
function print(k: string, v: string) {
150-
ux.stdout(`${snakeCase(k)}=${v}`)
151-
}
152-
153-
print('auto_cert_mgmt', info.app.acm)
154-
print('addons', addons)
155-
print('collaborators', collaborators)
156-
157-
if (info.app.archived_at) print('archived_at', formatDate(new Date(info.app.archived_at)))
158-
if (info.app.cron_finished_at) print('cron_finished_at', formatDate(new Date(info.app.cron_finished_at)))
159-
if (info.app.cron_next_run) print('cron_next_run', formatDate(new Date(info.app.cron_next_run)))
160-
if (info.app.database_size) print('database_size', filesize(info.app.database_size, {round: 0, standard: 'jedec'}))
161-
if (info.app.create_status !== 'complete') print('create_status', info.app.create_status)
162-
if (info.pipeline_coupling) print('pipeline', `${info.pipeline_coupling.pipeline.name}:${info.pipeline_coupling.stage}`)
163-
164-
print('git_url', info.app.git_url)
165-
print('web_url', info.app.web_url)
166-
print('repo_size', filesize(info.app.repo_size, {round: 0, standard: 'jedec'}))
167-
if (getGeneration(info.app) !== 'fir') print('slug_size', filesize(info.app.slug_size, {round: 0, standard: 'jedec'}))
168-
print('owner', info.app.owner.email)
169-
print('region', info.app.region.name)
170-
print('dynos', util.inspect(countBy(info.dynos, 'type')))
171-
print('stack', info.app.stack.name)
172-
}
173-
174-
if (flags.shell) {
175-
shell()
176-
} else if (flags.json) {
177-
hux.styledJSON(info)
178-
} else {
179-
print(info, addons, collaborators, flags.extended)
180-
}
181-
}
182-
}

src/commands/apps/transfer.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import {Command, flags} from '@heroku-cli/command'
22
import * as Heroku from '@heroku-cli/schema'
33
import * as color from '@heroku/heroku-cli-util/color'
44
import {Args, ux} from '@oclif/core'
5-
import inquirer from 'inquirer'
65
import tsheredoc from 'tsheredoc'
76

87
import {appTransfer} from '../../lib/apps/app-transfer.js'
8+
import {lazyModuleLoader} from '../../lib/lazy-module-loader.js'
99
import ConfirmCommand from '../../lib/confirmCommand.js'
1010
import {getOwner, isTeamApp, isValidEmail} from '../../lib/teamUtils.js'
1111
import AppsLock from './lock.js'
@@ -36,7 +36,7 @@ export default class AppsTransfer extends Command {
3636

3737
static topic = 'apps'
3838

39-
getAppsToTransfer(apps: Heroku.App[]) {
39+
getAppsToTransfer(apps: Heroku.App[], inquirer: any) {
4040
return inquirer.prompt([{
4141
choices: apps.map(app => ({
4242
name: `${color.app(app.name ?? '')} (${getOwner(app.owner?.email ?? '')})`, value: {name: app.name, owner: app.owner?.email},
@@ -49,12 +49,14 @@ export default class AppsTransfer extends Command {
4949
}
5050

5151
public async run() {
52+
const inquirer = await lazyModuleLoader.loadInquirer()
53+
5254
const {args, flags} = await this.parse(AppsTransfer)
5355
const {app, bulk, confirm, locked} = flags
5456
const {recipient} = args
5557
if (bulk) {
5658
const {body: allApps} = await this.heroku.get<Heroku.App[]>('/apps')
57-
const selectedApps = await this.getAppsToTransfer(allApps.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')))
59+
const selectedApps = await this.getAppsToTransfer(allApps.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')), inquirer)
5860
ux.warn(`Transferring applications to ${color.name(recipient)}...\n`)
5961
for (const app of selectedApps.choices) {
6062
try {

0 commit comments

Comments
 (0)