Skip to content

Commit 36ee7fa

Browse files
committed
feat(cr): cr
1 parent 4a20f9a commit 36ee7fa

File tree

8 files changed

+154
-123
lines changed

8 files changed

+154
-123
lines changed

src/cli.mts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { meowWithSubcommands } from './utils/meow-with-subcommands.mts'
2020
import { serializeResultJson } from './utils/serialize-result-json.mts'
2121
import {
2222
finalizeTelemetry,
23+
setupTelemetryExitHandlers,
2324
trackCliComplete,
2425
trackCliError,
2526
trackCliStart,
@@ -28,9 +29,15 @@ import { socketPackageLink } from './utils/terminal-link.mts'
2829

2930
const __filename = fileURLToPath(import.meta.url)
3031

32+
// Capture CLI start time at module level for global error handlers.
33+
const cliStartTime = Date.now()
34+
35+
// Set up telemetry exit handlers early to catch all exit scenarios.
36+
setupTelemetryExitHandlers()
37+
3138
void (async () => {
3239
// Track CLI start for telemetry.
33-
const cliStartTime = await trackCliStart(process.argv)
40+
await trackCliStart(process.argv)
3441

3542
const registryUrl = lookupRegistryUrl()
3643
await updateNotifier({
@@ -119,17 +126,13 @@ void (async () => {
119126
}
120127

121128
await captureException(e)
122-
} finally {
123-
// Finalize telemetry to ensure all events are sent.
124-
// This runs on both success and error paths.
125-
await finalizeTelemetry()
126129
}
127130
})().catch(async err => {
128131
// Fatal error in main async function.
129132
console.error('Fatal error:', err)
130133

131134
// Track CLI error for fatal exceptions.
132-
await trackCliError(process.argv, Date.now(), err, 1)
135+
await trackCliError(process.argv, cliStartTime, err, 1)
133136

134137
// Finalize telemetry before fatal exit.
135138
await finalizeTelemetry()
@@ -143,7 +146,7 @@ process.on('uncaughtException', async err => {
143146
console.error('Uncaught exception:', err)
144147

145148
// Track CLI error for uncaught exception.
146-
await trackCliError(process.argv, Date.now(), err, 1)
149+
await trackCliError(process.argv, cliStartTime, err, 1)
147150

148151
// Finalize telemetry before exit.
149152
await finalizeTelemetry()
@@ -158,7 +161,7 @@ process.on('unhandledRejection', async (reason, promise) => {
158161

159162
// Track CLI error for unhandled rejection.
160163
const error = reason instanceof Error ? reason : new Error(String(reason))
161-
await trackCliError(process.argv, Date.now(), error, 1)
164+
await trackCliError(process.argv, cliStartTime, error, 1)
162165

163166
// Finalize telemetry before exit.
164167
await finalizeTelemetry()

src/commands/npm/cmd-npm.mts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,17 @@ async function run(
102102
// See https://nodejs.org/api/child_process.html#event-exit.
103103
spawnPromise.process.on(
104104
'exit',
105-
async (code: number | null, signalName: NodeJS.Signals | null) => {
106-
// Track subprocess exit and flush telemetry.
107-
await trackSubprocessExit(NPM, subprocessStartTime, code)
108-
109-
if (signalName) {
110-
process.kill(process.pid, signalName)
111-
} else if (typeof code === 'number') {
112-
// eslint-disable-next-line n/no-process-exit
113-
process.exit(code)
114-
}
105+
(code: number | null, signalName: NodeJS.Signals | null) => {
106+
// Track subprocess exit and flush telemetry before exiting.
107+
// Use .then() to ensure telemetry completes before process.exit().
108+
void trackSubprocessExit(NPM, subprocessStartTime, code).then(() => {
109+
if (signalName) {
110+
process.kill(process.pid, signalName)
111+
} else if (typeof code === 'number') {
112+
// eslint-disable-next-line n/no-process-exit
113+
process.exit(code)
114+
}
115+
})
115116
},
116117
)
117118

src/commands/npx/cmd-npx.mts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,17 @@ async function run(
8787
// See https://nodejs.org/api/child_process.html#event-exit.
8888
spawnPromise.process.on(
8989
'exit',
90-
async (code: number | null, signalName: NodeJS.Signals | null) => {
91-
// Track subprocess exit and flush telemetry.
92-
await trackSubprocessExit(NPX, subprocessStartTime, code)
93-
94-
if (signalName) {
95-
process.kill(process.pid, signalName)
96-
} else if (typeof code === 'number') {
97-
// eslint-disable-next-line n/no-process-exit
98-
process.exit(code)
99-
}
90+
(code: number | null, signalName: NodeJS.Signals | null) => {
91+
// Track subprocess exit and flush telemetry before exiting.
92+
// Use .then() to ensure telemetry completes before process.exit().
93+
void trackSubprocessExit(NPX, subprocessStartTime, code).then(() => {
94+
if (signalName) {
95+
process.kill(process.pid, signalName)
96+
} else if (typeof code === 'number') {
97+
// eslint-disable-next-line n/no-process-exit
98+
process.exit(code)
99+
}
100+
})
100101
},
101102
)
102103

src/commands/pnpm/cmd-pnpm.mts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,17 @@ async function run(
9696
// See https://nodejs.org/api/child_process.html#event-exit.
9797
spawnPromise.process.on(
9898
'exit',
99-
async (code: number | null, signalName: NodeJS.Signals | null) => {
100-
// Track subprocess exit and flush telemetry.
101-
await trackSubprocessExit(PNPM, subprocessStartTime, code)
102-
103-
if (signalName) {
104-
process.kill(process.pid, signalName)
105-
} else if (typeof code === 'number') {
106-
// eslint-disable-next-line n/no-process-exit
107-
process.exit(code)
108-
}
99+
(code: number | null, signalName: NodeJS.Signals | null) => {
100+
// Track subprocess exit and flush telemetry before exiting.
101+
// Use .then() to ensure telemetry completes before process.exit().
102+
void trackSubprocessExit(PNPM, subprocessStartTime, code).then(() => {
103+
if (signalName) {
104+
process.kill(process.pid, signalName)
105+
} else if (typeof code === 'number') {
106+
// eslint-disable-next-line n/no-process-exit
107+
process.exit(code)
108+
}
109+
})
109110
},
110111
)
111112

src/commands/yarn/cmd-yarn.mts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,17 @@ async function run(
9696
// See https://nodejs.org/api/child_process.html#event-exit.
9797
spawnPromise.process.on(
9898
'exit',
99-
async (code: number | null, signalName: NodeJS.Signals | null) => {
100-
// Track subprocess exit and flush telemetry.
101-
await trackSubprocessExit(YARN, subprocessStartTime, code)
102-
103-
if (signalName) {
104-
process.kill(process.pid, signalName)
105-
} else if (typeof code === 'number') {
106-
// eslint-disable-next-line n/no-process-exit
107-
process.exit(code)
108-
}
99+
(code: number | null, signalName: NodeJS.Signals | null) => {
100+
// Track subprocess exit and flush telemetry before exiting.
101+
// Use .then() to ensure telemetry completes before process.exit().
102+
void trackSubprocessExit(YARN, subprocessStartTime, code).then(() => {
103+
if (signalName) {
104+
process.kill(process.pid, signalName)
105+
} else if (typeof code === 'number') {
106+
// eslint-disable-next-line n/no-process-exit
107+
process.exit(code)
108+
}
109+
})
109110
},
110111
)
111112

src/utils/telemetry/integration.mts

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
* Usage:
66
* ```typescript
77
* import {
8+
* setupTelemetryExitHandlers,
9+
* finalizeTelemetry,
10+
* finalizeTelemetrySync,
811
* trackCliStart,
912
* trackCliEvent,
1013
* trackCliComplete,
@@ -14,6 +17,9 @@
1417
* trackSubprocessError
1518
* } from './utils/telemetry/integration.mts'
1619
*
20+
* // Set up exit handlers once during CLI initialization.
21+
* setupTelemetryExitHandlers()
22+
*
1723
* // Track main CLI execution.
1824
* const startTime = await trackCliStart(process.argv)
1925
* await trackCliComplete(process.argv, startTime, 0)
@@ -27,12 +33,17 @@
2733
*
2834
* // On subprocess error.
2935
* await trackSubprocessError('npm', subStart, error, 1)
36+
*
37+
* // Manual finalization (usually not needed if exit handlers are set up).
38+
* await finalizeTelemetry() // Async version.
39+
* finalizeTelemetrySync() // Sync version (best-effort).
3040
* ```
3141
*/
3242
import { homedir } from 'node:os'
3343
import process from 'node:process'
3444

3545
import { debugFn } from '@socketsecurity/registry/lib/debug'
46+
import { escapeRegExp } from '@socketsecurity/registry/lib/regexps'
3647

3748
import { TelemetryService } from './service.mts'
3849
import constants, { CONFIG_KEY_DEFAULT_ORG } from '../../constants.mts'
@@ -48,8 +59,9 @@ const debug = (message: string): void => {
4859
}
4960

5061
/**
51-
* Finalize telemetry and clean up resources.
62+
* Finalize telemetry and clean up resources (async version).
5263
* This should be called before process.exit to ensure telemetry is sent and resources are cleaned up.
64+
* Use this in async contexts like beforeExit handlers.
5365
*
5466
* @returns Promise that resolves when finalization completes.
5567
*/
@@ -61,6 +73,76 @@ export async function finalizeTelemetry(): Promise<void> {
6173
}
6274
}
6375

76+
/**
77+
* Finalize telemetry synchronously (best-effort).
78+
* This triggers a flush without awaiting it.
79+
* Use this in synchronous contexts like signal handlers where async operations are not possible.
80+
*
81+
* Note: This is best-effort only. Events may be lost if the process exits before flush completes.
82+
* Prefer finalizeTelemetry() (async version) when possible.
83+
*/
84+
export function finalizeTelemetrySync(): void {
85+
const instance = TelemetryService.getCurrentInstance()
86+
if (instance) {
87+
debug('Triggering sync flush (best-effort)')
88+
void instance.flush()
89+
}
90+
}
91+
92+
/**
93+
* Set up exit handlers for telemetry finalization.
94+
* This registers handlers for both normal exits (beforeExit) and common fatal signals.
95+
*
96+
* Flushing strategy:
97+
* - Batch-based: Auto-flush when queue reaches 10 events.
98+
* - beforeExit: Async handler for clean shutdowns (when event loop empties).
99+
* - Fatal signals (SIGINT, SIGTERM, SIGHUP): Best-effort sync flush.
100+
* - Accepts that forced exits (SIGKILL, process.exit()) may lose final events.
101+
*
102+
* Call this once during CLI initialization to ensure telemetry is flushed on exit.
103+
*
104+
* @example
105+
* ```typescript
106+
* // In src/cli.mts
107+
* setupTelemetryExitHandlers()
108+
* ```
109+
*/
110+
export function setupTelemetryExitHandlers(): void {
111+
let handlersRegistered = false
112+
113+
// Use beforeExit for async finalization during clean shutdowns.
114+
// This fires when the event loop empties but before process actually exits.
115+
process.on('beforeExit', () => {
116+
if (!handlersRegistered) {
117+
return
118+
}
119+
debug('beforeExit handler triggered')
120+
void finalizeTelemetry()
121+
})
122+
123+
// Register handlers for common fatal signals as best-effort fallback.
124+
// These are synchronous contexts, so we can only trigger flush without awaiting.
125+
const fatalSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
126+
127+
for (const signal of fatalSignals) {
128+
try {
129+
process.on(signal, () => {
130+
if (!handlersRegistered) {
131+
return
132+
}
133+
debug(`Signal ${signal} received, attempting sync flush`)
134+
finalizeTelemetrySync()
135+
})
136+
} catch (e) {
137+
// Some signals may not be available on all platforms.
138+
debug(`Failed to register handler for signal ${signal}: ${e}`)
139+
}
140+
}
141+
142+
handlersRegistered = true
143+
debug('Telemetry exit handlers registered (beforeExit + common signals)')
144+
}
145+
64146
/**
65147
* Track subprocess exit and finalize telemetry.
66148
* This is a convenience function that tracks completion/error based on exit code
@@ -200,7 +282,7 @@ function sanitizeArgv(argv: string[]): string[] {
200282
// Remove user home directory from file paths.
201283
const homeDir = homedir()
202284
if (homeDir) {
203-
return arg.replace(new RegExp(homeDir, 'g'), '~')
285+
return arg.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~')
204286
}
205287

206288
return arg
@@ -222,7 +304,7 @@ function sanitizeErrorAttribute(input: string | undefined): string | undefined {
222304
// Remove user home directory.
223305
const homeDir = homedir()
224306
if (homeDir) {
225-
return input.replace(new RegExp(homeDir, 'g'), '~')
307+
return input.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~')
226308
}
227309

228310
return input
@@ -324,6 +406,7 @@ export async function trackCliEvent(
324406
/**
325407
* Track CLI completion event.
326408
* Should be called on successful CLI exit.
409+
* Events are flushed automatically by exit handlers.
327410
*
328411
* @param argv
329412
* @param startTime Start timestamp from trackCliStart.
@@ -336,22 +419,16 @@ export async function trackCliComplete(
336419
): Promise<void> {
337420
debug('Capture end of command')
338421

339-
await trackEvent(
340-
'cli_complete',
341-
buildContext(argv),
342-
{
343-
duration: calculateDuration(startTime),
344-
exit_code: normalizeExitCode(exitCode, 0),
345-
},
346-
{
347-
flush: true,
348-
},
349-
)
422+
await trackEvent('cli_complete', buildContext(argv), {
423+
duration: calculateDuration(startTime),
424+
exit_code: normalizeExitCode(exitCode, 0),
425+
})
350426
}
351427

352428
/**
353429
* Track CLI error event.
354430
* Should be called when CLI exits with an error.
431+
* Events are flushed automatically by exit handlers.
355432
*
356433
* @param argv
357434
* @param startTime Start timestamp from trackCliStart.
@@ -375,7 +452,6 @@ export async function trackCliError(
375452
},
376453
{
377454
error: normalizeError(error),
378-
flush: true,
379455
},
380456
)
381457
}

src/utils/telemetry/integration.test.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe('telemetry integration', () => {
242242
}),
243243
}),
244244
)
245-
expect(mockFlush).toHaveBeenCalled()
245+
// Note: Flush now happens automatically via exit handlers, not inline.
246246
})
247247

248248
it('normalizes exit code when string', async () => {
@@ -292,7 +292,7 @@ describe('telemetry integration', () => {
292292
}),
293293
}),
294294
)
295-
expect(mockFlush).toHaveBeenCalled()
295+
// Note: Flush now happens automatically via exit handlers, not inline.
296296
})
297297

298298
it('normalizes non-Error objects', async () => {

0 commit comments

Comments
 (0)