Skip to content

Commit 45edc03

Browse files
committed
feat(cr): cr
1 parent 4a20f9a commit 45edc03

File tree

7 files changed

+157
-110
lines changed

7 files changed

+157
-110
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: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,21 @@ 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

112113
await spawnPromise
114+
115+
process.exitCode = 0
113116
}

src/utils/telemetry/integration.mts

Lines changed: 94 additions & 4 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,79 @@ 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+
// Track whether exit handlers have been set up to prevent duplicate registration.
93+
let exitHandlersRegistered = false
94+
95+
/**
96+
* Set up exit handlers for telemetry finalization.
97+
* This registers handlers for both normal exits (beforeExit) and common fatal signals.
98+
*
99+
* Flushing strategy:
100+
* - Batch-based: Auto-flush when queue reaches 10 events.
101+
* - beforeExit: Async handler for clean shutdowns (when event loop empties).
102+
* - Fatal signals (SIGINT, SIGTERM, SIGHUP): Best-effort sync flush.
103+
* - Accepts that forced exits (SIGKILL, process.exit()) may lose final events.
104+
*
105+
* Call this once during CLI initialization to ensure telemetry is flushed on exit.
106+
* Safe to call multiple times - only registers handlers once.
107+
*
108+
* @example
109+
* ```typescript
110+
* // In src/cli.mts
111+
* setupTelemetryExitHandlers()
112+
* ```
113+
*/
114+
export function setupTelemetryExitHandlers(): void {
115+
// Prevent duplicate handler registration.
116+
if (exitHandlersRegistered) {
117+
debug('Telemetry exit handlers already registered, skipping')
118+
return
119+
}
120+
121+
exitHandlersRegistered = true
122+
123+
// Use beforeExit for async finalization during clean shutdowns.
124+
// This fires when the event loop empties but before process actually exits.
125+
process.on('beforeExit', () => {
126+
debug('beforeExit handler triggered')
127+
void finalizeTelemetry()
128+
})
129+
130+
// Register handlers for common fatal signals as best-effort fallback.
131+
// These are synchronous contexts, so we can only trigger flush without awaiting.
132+
const fatalSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
133+
134+
for (const signal of fatalSignals) {
135+
try {
136+
process.on(signal, () => {
137+
debug(`Signal ${signal} received, attempting sync flush`)
138+
finalizeTelemetrySync()
139+
})
140+
} catch (e) {
141+
// Some signals may not be available on all platforms.
142+
debug(`Failed to register handler for signal ${signal}: ${e}`)
143+
}
144+
}
145+
146+
debug('Telemetry exit handlers registered (beforeExit + common signals)')
147+
}
148+
64149
/**
65150
* Track subprocess exit and finalize telemetry.
66151
* This is a convenience function that tracks completion/error based on exit code
@@ -200,7 +285,7 @@ function sanitizeArgv(argv: string[]): string[] {
200285
// Remove user home directory from file paths.
201286
const homeDir = homedir()
202287
if (homeDir) {
203-
return arg.replace(new RegExp(homeDir, 'g'), '~')
288+
return arg.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~')
204289
}
205290

206291
return arg
@@ -222,15 +307,18 @@ function sanitizeErrorAttribute(input: string | undefined): string | undefined {
222307
// Remove user home directory.
223308
const homeDir = homedir()
224309
if (homeDir) {
225-
return input.replace(new RegExp(homeDir, 'g'), '~')
310+
return input.replace(new RegExp(escapeRegExp(homeDir), 'g'), '~')
226311
}
227312

228313
return input
229314
}
230315

231316
/**
232317
* Generic event tracking function.
233-
* Tracks any telemetry event with optional error details and flush.
318+
* Tracks any telemetry event with optional error details and explicit flush.
319+
*
320+
* Events are automatically flushed via batch size or exit handlers.
321+
* Use the flush option only when immediate submission is required.
234322
*
235323
* @param eventType Type of event to track.
236324
* @param context Event context.
@@ -324,6 +412,7 @@ export async function trackCliEvent(
324412
/**
325413
* Track CLI completion event.
326414
* Should be called on successful CLI exit.
415+
* Flushes immediately since this is typically the last event before process exit.
327416
*
328417
* @param argv
329418
* @param startTime Start timestamp from trackCliStart.
@@ -352,6 +441,7 @@ export async function trackCliComplete(
352441
/**
353442
* Track CLI error event.
354443
* Should be called when CLI exits with an error.
444+
* Flushes immediately since this is typically the last event before process exit.
355445
*
356446
* @param argv
357447
* @param startTime Start timestamp from trackCliStart.

0 commit comments

Comments
 (0)