Skip to content

Commit 72bd9c9

Browse files
authored
Capture stopwatch results (#12812)
Introduce `MeasurementResults` that capture a stopwatch execution in a serializable format. Add `onMeasurementResult` event emitter to stopwatch class and introduce optional caching of results. Add a metrics contribution to `@theia/metrics` that collects all measurement results of frontend and backend processes Contributed on behalf of STMicroelectronics
1 parent 2d31490 commit 72bd9c9

11 files changed

+280
-16
lines changed

packages/core/src/browser/performance/frontend-stopwatch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,26 @@ export class FrontendStopwatch extends Stopwatch {
4040
performance.mark(endMarker);
4141

4242
let duration: number;
43+
let startTime: number;
4344

4445
try {
4546
performance.measure(name, startMarker, endMarker);
4647

4748
const entries = performance.getEntriesByName(name);
4849
// If no entries, then performance measurement was disabled or failed, so
4950
// signal that with a `NaN` result
50-
duration = entries.length > 0 ? entries[0].duration : Number.NaN;
51+
duration = entries[0].duration ?? Number.NaN;
52+
startTime = entries[0].startTime ?? Number.NaN;
5153
} catch (e) {
5254
console.warn(e);
5355
duration = Number.NaN;
56+
startTime = Number.NaN;
5457
}
5558

5659
performance.clearMeasures(name);
5760
performance.clearMarks(startMarker);
5861
performance.clearMarks(endMarker);
59-
return duration;
62+
return { startTime, duration };
6063
}, options);
6164
}
6265
};

packages/core/src/common/performance/measurement.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,30 @@ export interface MeasurementOptions {
101101
* @see {@link thresholdLogLevel}
102102
*/
103103
thresholdMillis?: number;
104+
105+
/**
106+
* Flag to indicate whether the stopwatch should store measurement results for later retrieval.
107+
* For example the cache can be used to retrieve measurements which were taken during startup before a listener had a chance to register.
108+
*/
109+
storeResults?: boolean
110+
}
111+
112+
/**
113+
* Captures the result of a {@link Measurement} in a serializable format.
114+
*/
115+
export interface MeasurementResult {
116+
/** The measurement name. This may show up in the performance measurement framework appropriate to the application context. */
117+
name: string;
118+
119+
/** The time when the measurement recording has been started */
120+
startTime: number;
121+
122+
/**
123+
* The elapsed time measured, if it has been {@link stop stopped} and measured, or `NaN` if the platform disabled
124+
* performance measurement.
125+
*/
126+
elapsed: number;
127+
128+
/** An optional label for the application the start of which (in real time) is the basis of all measurements. */
129+
owner?: string;
104130
}

packages/core/src/common/performance/stopwatch.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
import { inject, injectable } from 'inversify';
2020
import { ILogger, LogLevel } from '../logger';
2121
import { MaybePromise } from '../types';
22-
import { Measurement, MeasurementOptions } from './measurement';
22+
import { Measurement, MeasurementOptions, MeasurementResult } from './measurement';
23+
import { Emitter, Event } from '../event';
2324

2425
/** The default log level for measurements that are not otherwise configured with a default. */
2526
const DEFAULT_LOG_LEVEL = LogLevel.INFO;
@@ -50,10 +51,20 @@ export abstract class Stopwatch {
5051
@inject(ILogger)
5152
protected readonly logger: ILogger;
5253

54+
protected _storedMeasurements: MeasurementResult[] = [];
55+
56+
protected onDidAddMeasurementResultEmitter = new Emitter<MeasurementResult>();
57+
get onDidAddMeasurementResult(): Event<MeasurementResult> {
58+
return this.onDidAddMeasurementResultEmitter.event;
59+
}
60+
5361
constructor(protected readonly defaultLogOptions: LogOptions) {
5462
if (!defaultLogOptions.defaultLogLevel) {
5563
defaultLogOptions.defaultLogLevel = DEFAULT_LOG_LEVEL;
5664
}
65+
if (defaultLogOptions.storeResults === undefined) {
66+
defaultLogOptions.storeResults = true;
67+
}
5768
}
5869

5970
/**
@@ -91,25 +102,36 @@ export abstract class Stopwatch {
91102
return result;
92103
}
93104

94-
protected createMeasurement(name: string, measurement: () => number, options?: MeasurementOptions): Measurement {
105+
protected createMeasurement(name: string, measure: () => { startTime: number, duration: number }, options?: MeasurementOptions): Measurement {
95106
const logOptions = this.mergeLogOptions(options);
96107

97-
const result: Measurement = {
108+
const measurement: Measurement = {
98109
name,
99110
stop: () => {
100-
if (result.elapsed === undefined) {
101-
result.elapsed = measurement();
111+
if (measurement.elapsed === undefined) {
112+
const { startTime, duration } = measure();
113+
measurement.elapsed = duration;
114+
const result: MeasurementResult = {
115+
name,
116+
elapsed: duration,
117+
startTime,
118+
owner: logOptions.owner
119+
};
120+
if (logOptions.storeResults) {
121+
this._storedMeasurements.push(result);
122+
}
123+
this.onDidAddMeasurementResultEmitter.fire(result);
102124
}
103-
return result.elapsed;
125+
return measurement.elapsed;
104126
},
105-
log: (activity: string, ...optionalArgs: any[]) => this.log(result, activity, this.atLevel(logOptions, undefined, optionalArgs)),
106-
debug: (activity: string, ...optionalArgs: any[]) => this.log(result, activity, this.atLevel(logOptions, LogLevel.DEBUG, optionalArgs)),
107-
info: (activity: string, ...optionalArgs: any[]) => this.log(result, activity, this.atLevel(logOptions, LogLevel.INFO, optionalArgs)),
108-
warn: (activity: string, ...optionalArgs: any[]) => this.log(result, activity, this.atLevel(logOptions, LogLevel.WARN, optionalArgs)),
109-
error: (activity: string, ...optionalArgs: any[]) => this.log(result, activity, this.atLevel(logOptions, LogLevel.ERROR, optionalArgs)),
127+
log: (activity: string, ...optionalArgs: any[]) => this.log(measurement, activity, this.atLevel(logOptions, undefined, optionalArgs)),
128+
debug: (activity: string, ...optionalArgs: any[]) => this.log(measurement, activity, this.atLevel(logOptions, LogLevel.DEBUG, optionalArgs)),
129+
info: (activity: string, ...optionalArgs: any[]) => this.log(measurement, activity, this.atLevel(logOptions, LogLevel.INFO, optionalArgs)),
130+
warn: (activity: string, ...optionalArgs: any[]) => this.log(measurement, activity, this.atLevel(logOptions, LogLevel.WARN, optionalArgs)),
131+
error: (activity: string, ...optionalArgs: any[]) => this.log(measurement, activity, this.atLevel(logOptions, LogLevel.ERROR, optionalArgs)),
110132
};
111133

112-
return result;
134+
return measurement;
113135
}
114136

115137
protected mergeLogOptions(logOptions?: Partial<LogOptions>): LogOptions {
@@ -154,4 +176,8 @@ export abstract class Stopwatch {
154176
this.logger.log(level, `${whatWasMeasured}: ${elapsed.toFixed(1)} ms [${timeFromStart}]`, ...(options.arguments ?? []));
155177
}
156178

179+
get storedMeasurements(): ReadonlyArray<MeasurementResult> {
180+
return this._storedMeasurements;
181+
}
182+
157183
}

packages/core/src/node/performance/node-stopwatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class NodeStopwatch extends Stopwatch {
3333

3434
return this.createMeasurement(name, () => {
3535
const duration = performance.now() - startTime;
36-
return duration;
36+
return { duration, startTime };
3737
}, options);
3838
}
3939

packages/metrics/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"theiaExtensions": [
1313
{
14+
"frontend": "lib/browser/metrics-frontend-module",
1415
"backend": "lib/node/metrics-backend-module"
1516
}
1617
],
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2023 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
import { inject, injectable } from '@theia/core/shared/inversify';
17+
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
18+
import { ILogger, LogLevel, MeasurementResult, Stopwatch } from '@theia/core';
19+
import { UUID } from '@theia/core/shared/@phosphor/coreutils';
20+
import { MeasurementNotificationService } from '../common';
21+
22+
@injectable()
23+
export class MetricsFrontendApplicationContribution implements FrontendApplicationContribution {
24+
@inject(Stopwatch)
25+
protected stopwatch: Stopwatch;
26+
27+
@inject(MeasurementNotificationService)
28+
protected notificationService: MeasurementNotificationService;
29+
30+
@inject(ILogger)
31+
protected logger: ILogger;
32+
33+
readonly id = UUID.uuid4();
34+
35+
initialize(): void {
36+
this.doInitialize();
37+
}
38+
39+
protected async doInitialize(): Promise<void> {
40+
const logLevel = await this.logger.getLogLevel();
41+
if (logLevel !== LogLevel.DEBUG) {
42+
return;
43+
}
44+
this.stopwatch.storedMeasurements.forEach(result => this.notify(result));
45+
this.stopwatch.onDidAddMeasurementResult(result => this.notify(result));
46+
}
47+
48+
protected notify(result: MeasurementResult): void {
49+
this.notificationService.onFrontendMeasurement(this.id, result);
50+
}
51+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2023 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { ContainerModule } from '@theia/core/shared/inversify';
18+
import { MetricsFrontendApplicationContribution } from './metrics-frontend-application-contribution';
19+
import { MeasurementNotificationService, measurementNotificationServicePath } from '../common';
20+
import { FrontendApplicationContribution, WebSocketConnectionProvider } from '@theia/core/lib/browser';
21+
22+
export default new ContainerModule(bind => {
23+
bind(FrontendApplicationContribution).to(MetricsFrontendApplicationContribution).inSingletonScope();
24+
bind(MeasurementNotificationService).toDynamicValue(ctx => {
25+
const connection = ctx.container.get(WebSocketConnectionProvider);
26+
return connection.createProxy<MeasurementNotificationService>(measurementNotificationServicePath);
27+
});
28+
});

packages/metrics/src/common/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2023 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
export * from './measurement-notification-service';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2023 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { MeasurementResult } from '@theia/core';
18+
19+
export const measurementNotificationServicePath = '/services/measurement-notification';
20+
21+
export const MeasurementNotificationService = Symbol('MeasurementNotificationService');
22+
export interface MeasurementNotificationService {
23+
/**
24+
* Notify the backend when a fronted stopwatch provides a new measurement.
25+
* @param frontendId The unique id associated with the frontend that sends the notification
26+
* @param result The new measurement result
27+
*/
28+
onFrontendMeasurement(frontendId: string, result: MeasurementResult): void;
29+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2023 STMicroelectronics and others.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
import { inject, injectable, } from '@theia/core/shared/inversify';
17+
import { MetricsContribution } from './metrics-contribution';
18+
import { LogLevel, MeasurementResult, Stopwatch } from '@theia/core';
19+
import { MeasurementNotificationService } from '../common';
20+
import { LogLevelCliContribution } from '@theia/core/lib/node/logger-cli-contribution';
21+
22+
const backendId = 'backend';
23+
const metricsName = 'theia_measurements';
24+
25+
@injectable()
26+
export class MeasurementMetricsBackendContribution implements MetricsContribution, MeasurementNotificationService {
27+
@inject(Stopwatch)
28+
protected backendStopwatch: Stopwatch;
29+
30+
@inject(LogLevelCliContribution)
31+
protected logLevelCli: LogLevelCliContribution;
32+
33+
protected metrics = '';
34+
protected frontendCounters = new Map<string, string>();
35+
36+
startCollecting(): void {
37+
if (this.logLevelCli.defaultLogLevel !== LogLevel.DEBUG) {
38+
return;
39+
}
40+
this.metrics += `# HELP ${metricsName} Theia stopwatch measurement results.\n`;
41+
this.metrics += `# TYPE ${metricsName} gauge\n`;
42+
this.backendStopwatch.storedMeasurements.forEach(result => this.onBackendMeasurement(result));
43+
this.backendStopwatch.onDidAddMeasurementResult(result => this.onBackendMeasurement(result));
44+
}
45+
46+
getMetrics(): string {
47+
return this.metrics;
48+
}
49+
50+
protected appendMetricsValue(id: string, result: MeasurementResult): void {
51+
const { name, elapsed, startTime, owner } = result;
52+
const labels: string = `id="${id}", name="${name}", startTime="${startTime}", owner="${owner}"`;
53+
const metricsValue = `${metricsName}{${labels}} ${elapsed}`;
54+
this.metrics += (metricsValue + '\n');
55+
}
56+
57+
protected onBackendMeasurement(result: MeasurementResult): void {
58+
this.appendMetricsValue(backendId, result);
59+
}
60+
61+
protected createFrontendCounterId(frontendId: string): string {
62+
const counterId = `frontend-${this.frontendCounters.size + 1}`;
63+
this.frontendCounters.set(frontendId, counterId);
64+
return counterId;
65+
}
66+
67+
protected toCounterId(frontendId: string): string {
68+
return this.frontendCounters.get(frontendId) ?? this.createFrontendCounterId(frontendId);
69+
}
70+
71+
onFrontendMeasurement(frontendId: string, result: MeasurementResult): void {
72+
this.appendMetricsValue(this.toCounterId(frontendId), result);
73+
}
74+
75+
}

packages/metrics/src/node/metrics-backend-module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,25 @@
1616

1717
import { ContainerModule } from '@theia/core/shared/inversify';
1818
import { BackendApplicationContribution } from '@theia/core/lib/node';
19-
import { bindContributionProvider } from '@theia/core/lib/common';
19+
import { ConnectionHandler, RpcConnectionHandler, bindContributionProvider } from '@theia/core/lib/common';
2020
import { MetricsContribution } from './metrics-contribution';
2121
import { NodeMetricsContribution } from './node-metrics-contribution';
2222
import { ExtensionMetricsContribution } from './extensions-metrics-contribution';
2323
import { MetricsBackendApplicationContribution } from './metrics-backend-application-contribution';
24+
import { measurementNotificationServicePath } from '../common';
25+
import { MeasurementMetricsBackendContribution } from './measurement-metrics-contribution';
2426

2527
export default new ContainerModule(bind => {
2628
bindContributionProvider(bind, MetricsContribution);
2729
bind(MetricsContribution).to(NodeMetricsContribution).inSingletonScope();
2830
bind(MetricsContribution).to(ExtensionMetricsContribution).inSingletonScope();
2931

32+
bind(MeasurementMetricsBackendContribution).toSelf().inSingletonScope();
33+
bind(MetricsContribution).toService(MeasurementMetricsBackendContribution);
34+
bind(ConnectionHandler).toDynamicValue(ctx =>
35+
new RpcConnectionHandler(measurementNotificationServicePath,
36+
() => ctx.container.get(MeasurementMetricsBackendContribution)));
37+
3038
bind(BackendApplicationContribution).to(MetricsBackendApplicationContribution).inSingletonScope();
3139

3240
});

0 commit comments

Comments
 (0)