Skip to content

Commit b0734af

Browse files
authored
Move log activation off the critical extension activation path (#1913)
* Move log activation off the critical extension activation path Initializing both the file transport and containing folder for the log files was blocking extension activation, sometimes taking up to 1,500ms. Move log activation off the critical path of extension activation by avoiding awaiting promises before continuing activation. Any logs logged before the file transport is ready are buffered and replayed into the file transport once file system operations are complete. This cuts extension activation time down by about 50%. This patch also adds timing metrics to extension startup, printing them to the logs, and also adds a new transport to print to the console when doing extension development.
1 parent 43d8927 commit b0734af

File tree

5 files changed

+219
-31
lines changed

5 files changed

+219
-31
lines changed

src/WorkspaceContext.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,12 @@ export class WorkspaceContext implements vscode.Disposable {
350350
// add workspace folders, already loaded
351351
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
352352
for (const folder of vscode.workspace.workspaceFolders) {
353+
const singleFolderStartTime = Date.now();
353354
await this.addWorkspaceFolder(folder);
355+
const singleFolderElapsed = Date.now() - singleFolderStartTime;
356+
this.logger.info(
357+
`Added workspace folder ${folder.name} in ${singleFolderElapsed}ms`
358+
);
354359
}
355360
}
356361

@@ -444,17 +449,30 @@ export class WorkspaceContext implements vscode.Disposable {
444449
* @param folder folder being added
445450
*/
446451
async addWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder) {
452+
const searchStartTime = Date.now();
447453
const folders = await searchForPackages(
448454
workspaceFolder.uri,
449455
configuration.disableSwiftPMIntegration,
450456
configuration.folder(workspaceFolder).searchSubfoldersForPackages,
451457
configuration.folder(workspaceFolder).ignoreSearchingForPackagesInSubfolders,
452458
this.globalToolchainSwiftVersion
453459
);
460+
const searchElapsed = Date.now() - searchStartTime;
461+
this.logger.info(
462+
`Package search for ${workspaceFolder.name} completed in ${searchElapsed}ms (found ${folders.length} packages)`
463+
);
454464

465+
const addPackagesStartTime = Date.now();
455466
for (const folder of folders) {
467+
const singlePackageStartTime = Date.now();
456468
await this.addPackageFolder(folder, workspaceFolder);
469+
const singlePackageElapsed = Date.now() - singlePackageStartTime;
470+
this.logger.info(`Added package folder ${folder.fsPath} in ${singlePackageElapsed}ms`);
457471
}
472+
const addPackagesElapsed = Date.now() - addPackagesStartTime;
473+
this.logger.info(
474+
`All package folders for ${workspaceFolder.name} added in ${addPackagesElapsed}ms`
475+
);
458476

459477
if (this.getActiveWorkspaceFolder(vscode.window.activeTextEditor) === workspaceFolder) {
460478
await this.focusTextEditor(vscode.window.activeTextEditor);

src/extension.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,28 @@ export interface Api {
5454
* Activate the extension. This is the main entry point.
5555
*/
5656
export async function activate(context: vscode.ExtensionContext): Promise<Api> {
57+
const activationStartTime = Date.now();
5758
try {
58-
await vscode.workspace.fs.createDirectory(context.logUri);
59-
const logger = new SwiftLoggerFactory(context.logUri).create(
60-
"Swift",
61-
"swift-vscode-extension.log"
62-
);
63-
context.subscriptions.push(logger);
59+
const logSetupStartTime = Date.now();
60+
const logger = configureLogging(context);
61+
const logSetupElapsed = Date.now() - logSetupStartTime;
6462
logger.info(
6563
`Activating Swift for Visual Studio Code ${context.extension.packageJSON.version}...`
6664
);
65+
logger.info(`Log setup completed in ${logSetupElapsed}ms`);
6766

67+
const preToolchainStartTime = Date.now();
6868
checkAndWarnAboutWindowsSymlinks(logger);
6969

7070
const contextKeys = createContextKeys();
71+
const preToolchainElapsed = Date.now() - preToolchainStartTime;
72+
const toolchainStartTime = Date.now();
7173
const toolchain = await createActiveToolchain(context, contextKeys, logger);
74+
const toolchainElapsed = Date.now() - toolchainStartTime;
75+
76+
const swiftlyCheckStartTime = Date.now();
7277
checkForSwiftlyInstallation(contextKeys, logger);
78+
const swiftlyCheckElapsed = Date.now() - swiftlyCheckStartTime;
7379

7480
// If we don't have a toolchain, show an error and stop initializing the extension.
7581
// This can happen if the user has not installed Swift or if the toolchain is not
@@ -95,9 +101,12 @@ export async function activate(context: vscode.ExtensionContext): Promise<Api> {
95101
}
96102
}
97103

104+
const workspaceContextStartTime = Date.now();
98105
const workspaceContext = new WorkspaceContext(context, contextKeys, logger, toolchain);
99106
context.subscriptions.push(workspaceContext);
107+
const workspaceContextElapsed = Date.now() - workspaceContextStartTime;
100108

109+
const subscriptionsStartTime = Date.now();
101110
context.subscriptions.push(new SwiftEnvironmentVariablesManager(context));
102111
context.subscriptions.push(SwiftTerminalProfileProvider.register());
103112
context.subscriptions.push(
@@ -152,12 +161,22 @@ export async function activate(context: vscode.ExtensionContext): Promise<Api> {
152161
context.subscriptions.push(TestExplorer.observeFolders(workspaceContext));
153162

154163
context.subscriptions.push(registerSourceKitSchemaWatcher(workspaceContext));
164+
const subscriptionsElapsed = Date.now() - subscriptionsStartTime;
155165

156166
// setup workspace context with initial workspace folders
157-
void workspaceContext.addWorkspaceFolders();
167+
const workspaceFoldersStartTime = Date.now();
168+
await workspaceContext.addWorkspaceFolders();
169+
const workspaceFoldersElapsed = Date.now() - workspaceFoldersStartTime;
158170

171+
const finalStepsStartTime = Date.now();
159172
// Mark the extension as activated.
160173
contextKeys.isActivated = true;
174+
const finalStepsElapsed = Date.now() - finalStepsStartTime;
175+
176+
const totalActivationTime = Date.now() - activationStartTime;
177+
logger.info(
178+
`Extension activation completed in ${totalActivationTime}ms (log-setup: ${logSetupElapsed}ms, pre-toolchain: ${preToolchainElapsed}ms, toolchain: ${toolchainElapsed}ms, swiftly-check: ${swiftlyCheckElapsed}ms, workspace-context: ${workspaceContextElapsed}ms, subscriptions: ${subscriptionsElapsed}ms, workspace-folders: ${workspaceFoldersElapsed}ms, final-steps: ${finalStepsElapsed}ms)`
179+
);
161180

162181
return {
163182
workspaceContext,
@@ -177,6 +196,26 @@ export async function activate(context: vscode.ExtensionContext): Promise<Api> {
177196
}
178197
}
179198

199+
function configureLogging(context: vscode.ExtensionContext) {
200+
// Create log directory asynchronously but don't await it to avoid blocking activation
201+
const logDirPromise = vscode.workspace.fs.createDirectory(context.logUri);
202+
203+
const logger = new SwiftLoggerFactory(context.logUri).create(
204+
"Swift",
205+
"swift-vscode-extension.log"
206+
);
207+
context.subscriptions.push(logger);
208+
209+
void Promise.resolve(logDirPromise)
210+
.then(() => {
211+
// File transport will be added when directory is ready
212+
})
213+
.catch((error: unknown) => {
214+
logger.warn(`Failed to create log directory: ${error}`);
215+
});
216+
return logger;
217+
}
218+
180219
function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise<void> {
181220
// function called when a folder is added. I broke this out so we can trigger it
182221
// without having to await for it.

src/logging/FileTransport.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import * as fs from "fs";
15+
import * as path from "path";
16+
import * as TransportType from "winston-transport";
17+
18+
// Compile error if don't use "require": https://github.com/swiftlang/vscode-swift/actions/runs/16529946578/job/46752753379?pr=1746
19+
// eslint-disable-next-line @typescript-eslint/no-require-imports
20+
const Transport: typeof TransportType = require("winston-transport");
21+
22+
export class FileTransport extends Transport {
23+
private fileHandle: fs.WriteStream | null = null;
24+
private pendingLogs: string[] = [];
25+
private isReady = false;
26+
27+
constructor(private readonly filePath: string) {
28+
super();
29+
this.initializeFile();
30+
}
31+
32+
private initializeFile(): void {
33+
try {
34+
// Ensure the directory exists
35+
const dir = path.dirname(this.filePath);
36+
if (!fs.existsSync(dir)) {
37+
fs.mkdirSync(dir, { recursive: true });
38+
}
39+
40+
// Create write stream
41+
this.fileHandle = fs.createWriteStream(this.filePath, { flags: "a" });
42+
43+
this.fileHandle.on("ready", () => {
44+
this.isReady = true;
45+
this.flushPendingLogs();
46+
});
47+
48+
this.fileHandle.on("error", error => {
49+
// eslint-disable-next-line no-console
50+
console.error(`FileTransport error: ${error}`);
51+
this.isReady = false;
52+
});
53+
} catch (error) {
54+
// eslint-disable-next-line no-console
55+
console.error(`Failed to initialize FileTransport: ${error}`);
56+
this.isReady = false;
57+
}
58+
}
59+
60+
private flushPendingLogs(): void {
61+
if (this.fileHandle && this.pendingLogs.length > 0) {
62+
for (const log of this.pendingLogs) {
63+
this.fileHandle.write(log + "\n");
64+
}
65+
this.pendingLogs = [];
66+
}
67+
}
68+
69+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70+
public log(info: any, next: () => void): void {
71+
// Get the formatted message from winston
72+
const logMessage = info[Symbol.for("message")];
73+
74+
if (this.isReady && this.fileHandle) {
75+
this.fileHandle.write(logMessage + "\n");
76+
} else {
77+
// Buffer logs if file isn't ready yet
78+
this.pendingLogs.push(logMessage);
79+
}
80+
81+
next();
82+
}
83+
84+
public close(): void {
85+
if (this.fileHandle) {
86+
this.fileHandle.end();
87+
this.fileHandle = null;
88+
}
89+
this.isReady = false;
90+
this.pendingLogs = [];
91+
}
92+
}

src/logging/SwiftLogger.ts

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import * as vscode from "vscode";
1515
import * as winston from "winston";
1616

1717
import configuration from "../configuration";
18-
import { IS_RUNNING_UNDER_TEST } from "../utilities/utilities";
18+
import { IS_RUNNING_IN_DEVELOPMENT_MODE, IS_RUNNING_UNDER_TEST } from "../utilities/utilities";
19+
import { FileTransport } from "./FileTransport";
1920
import { OutputChannelTransport } from "./OutputChannelTransport";
2021
import { RollingLog } from "./RollingLog";
2122
import { RollingLogTransport } from "./RollingLogTransport";
@@ -31,6 +32,8 @@ export class SwiftLogger implements vscode.Disposable {
3132
private logger: winston.Logger;
3233
protected rollingLog: RollingLog;
3334
protected outputChannel: vscode.OutputChannel;
35+
private fileTransport: FileTransport;
36+
private cachedOutputChannelLevel: string | undefined;
3437

3538
constructor(
3639
public readonly name: string,
@@ -42,16 +45,24 @@ export class SwiftLogger implements vscode.Disposable {
4245
const ouptutChannelTransport = new OutputChannelTransport(this.outputChannel);
4346
ouptutChannelTransport.level = this.outputChannelLevel;
4447
const rollingLogTransport = new RollingLogTransport(this.rollingLog);
48+
49+
// Create file transport
50+
this.fileTransport = new FileTransport(this.logFilePath);
51+
this.fileTransport.level = "debug"; // File logging at the 'debug' level always
52+
53+
// Create logger with all transports
54+
const transports = [
55+
ouptutChannelTransport,
56+
this.fileTransport,
57+
// Only want to capture the rolling log in memory when testing
58+
...(IS_RUNNING_UNDER_TEST ? [rollingLogTransport] : []),
59+
...(IS_RUNNING_IN_DEVELOPMENT_MODE
60+
? [new winston.transports.Console({ level: "debug" })]
61+
: []),
62+
];
63+
4564
this.logger = winston.createLogger({
46-
transports: [
47-
new winston.transports.File({
48-
filename: this.logFilePath,
49-
level: "debug", // File logging at the 'debug' level always
50-
}),
51-
ouptutChannelTransport,
52-
// Only want to capture the rolling log in memory when testing
53-
...(IS_RUNNING_UNDER_TEST ? [rollingLogTransport] : []),
54-
],
65+
transports: transports,
5566
format: winston.format.combine(
5667
winston.format.errors({ stack: true }),
5768
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), // This is the format of `vscode.LogOutputChannel`
@@ -71,37 +82,53 @@ export class SwiftLogger implements vscode.Disposable {
7182
if (rollingLogTransport.close) {
7283
rollingLogTransport.close();
7384
}
85+
this.fileTransport.close();
7486
},
7587
},
7688
vscode.workspace.onDidChangeConfiguration(e => {
7789
if (
7890
e.affectsConfiguration("swift.outputChannelLogLevel") ||
7991
e.affectsConfiguration("swift.diagnostics")
8092
) {
93+
// Clear cache when configuration changes
94+
this.cachedOutputChannelLevel = undefined;
8195
ouptutChannelTransport.level = this.outputChannelLevel;
8296
}
8397
})
8498
);
8599
}
86100

87101
debug(message: LoggerMeta, label?: string, options?: LogMessageOptions) {
88-
this.logger.debug(this.normalizeMessage(message, label), options);
102+
const normalizedMessage = this.normalizeMessage(message, label);
103+
this.logWithBuffer("debug", normalizedMessage, options);
89104
}
90105

91106
info(message: LoggerMeta, label?: string, options?: LogMessageOptions) {
92-
this.logger.info(this.normalizeMessage(message, label), options);
107+
const normalizedMessage = this.normalizeMessage(message, label);
108+
this.logWithBuffer("info", normalizedMessage, options);
93109
}
94110

95111
warn(message: LoggerMeta, label?: string, options?: LogMessageOptions) {
96-
this.logger.warn(this.normalizeMessage(message, label), options);
112+
const normalizedMessage = this.normalizeMessage(message, label);
113+
this.logWithBuffer("warn", normalizedMessage, options);
97114
}
98115

99116
error(message: LoggerMeta, label?: string, options?: LogMessageOptions) {
100117
if (message instanceof Error) {
101-
this.logger.error(message);
118+
this.logWithBuffer("error", message);
102119
return;
103120
}
104-
this.logger.error(this.normalizeMessage(message, label), options);
121+
const normalizedMessage = this.normalizeMessage(message, label);
122+
this.logWithBuffer("error", normalizedMessage, options);
123+
}
124+
125+
private logWithBuffer(level: string, message: string | Error, meta?: LoggerMeta) {
126+
// Log to all transports (output channel, file, console, etc.)
127+
if (message instanceof Error) {
128+
this.logger.log(level, message);
129+
} else {
130+
this.logger.log(level, message, meta);
131+
}
105132
}
106133

107134
get logs(): string[] {
@@ -131,16 +158,22 @@ export class SwiftLogger implements vscode.Disposable {
131158
}
132159

133160
private get outputChannelLevel(): string {
134-
const info = vscode.workspace.getConfiguration("swift").inspect("outputChannelLogLevel");
135-
// If the user has explicitly set `outputChannelLogLevel` then use it, otherwise
136-
// check the deprecated `diagnostics` property
137-
if (info?.globalValue || info?.workspaceValue || info?.workspaceFolderValue) {
138-
return configuration.outputChannelLogLevel;
139-
} else if (configuration.diagnostics) {
140-
return "debug";
141-
} else {
142-
return configuration.outputChannelLogLevel;
161+
// Cache the configuration lookup to avoid repeated expensive calls during initialization
162+
if (this.cachedOutputChannelLevel === undefined) {
163+
const info = vscode.workspace
164+
.getConfiguration("swift")
165+
.inspect("outputChannelLogLevel");
166+
// If the user has explicitly set `outputChannelLogLevel` then use it, otherwise
167+
// check the deprecated `diagnostics` property
168+
if (info?.globalValue || info?.workspaceValue || info?.workspaceFolderValue) {
169+
this.cachedOutputChannelLevel = configuration.outputChannelLogLevel;
170+
} else if (configuration.diagnostics) {
171+
this.cachedOutputChannelLevel = "debug";
172+
} else {
173+
this.cachedOutputChannelLevel = configuration.outputChannelLogLevel;
174+
}
143175
}
176+
return this.cachedOutputChannelLevel;
144177
}
145178

146179
dispose() {

src/utilities/utilities.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export const IS_RUNNING_UNDER_DOCKER = IS_RUNNING_UNDER_ACT || IS_RUNNING_UNDER_
5050
*/
5151
export const IS_RUNNING_UNDER_TEST = process.env.RUNNING_UNDER_VSCODE_TEST_CLI === "1";
5252

53+
/**
54+
* Determined by the presence of the `VSCODE_DEBUG` environment variable, set by the
55+
* launch.json when starting the extension in development.
56+
*/
57+
export const IS_RUNNING_IN_DEVELOPMENT_MODE = process.env["VSCODE_DEBUG"] === "1";
58+
5359
/** Determines whether the provided object has any properties set to non-null values. */
5460
export function isEmptyObject(obj: { [key: string]: unknown }): boolean {
5561
const properties = Object.getOwnPropertyNames(obj).filter(

0 commit comments

Comments
 (0)