Loading src/common/language_server/language_client_wrapper.test.ts +0 −8 Original line number Diff line number Diff line Loading @@ -46,13 +46,6 @@ import { LanguageServerStartupMonitor } from './language_server_startup_monitor' jest.mock('../code_suggestions/gitlab_platform_manager_for_code_suggestions'); jest.mock('../log'); // disable logging in tests jest.mock('../feature_flags/local_feature_flag_service'); jest.mock('../snowplow/snowplow', () => ({ Snowplow: { getInstance: jest .fn() .mockReturnValue({ trackStructEvent: jest.fn().mockResolvedValue(undefined) }), }, })); jest.mock('../webview'); Loading Loading @@ -123,7 +116,6 @@ describe('LanguageClientWrapper', () => { const createWrapperWithMonitor = (options: Parameters<typeof createWrapper>[0] = {}) => { const wrapperWithMonitor = createWrapper(options); wrapperWithMonitor.startupMonitor = new LanguageServerStartupMonitor( options.mockTelemetryEnvironment ?? gitLabTelemetryEnvironment, new NoopObservabilityService(), ); return wrapperWithMonitor; Loading src/common/language_server/language_server_manager.ts +1 −4 Original line number Diff line number Diff line Loading @@ -112,10 +112,7 @@ export class LanguageServerManager implements WebviewManager, VersionProvider { ); const gutterIcon = new CodeSuggestionsGutterIcon(this.#context, stateManager); const middleware = new LanguageClientMiddleware(stateManager); const startupMonitor = new LanguageServerStartupMonitor( gitLabTelemetryEnvironment, observabilityService, ); const startupMonitor = new LanguageServerStartupMonitor(observabilityService); const statusBarItem = new DuoStatusBarItem(stateManager, startupMonitor); const startupStatusBarItem = new LanguageServerStartupStatusBarItem(startupMonitor); const baseAssetsUrl = vscode.Uri.joinPath( Loading src/common/language_server/language_server_startup_monitor.test.ts +1 −87 Original line number Diff line number Diff line import { BaseLanguageClient, State, StateChangeEvent } from 'vscode-languageclient'; import { createFakePartial } from '../test_utils/create_fake_partial'; import { log } from '../log'; import { Snowplow } from '../snowplow/snowplow'; import { GitLabTelemetryEnvironment } from '../platform/gitlab_telemetry_environment'; import { NoopObservabilityService } from '../observability'; import { LanguageServerStartupMonitor } from './language_server_startup_monitor'; jest.mock('../log'); jest.mock('../snowplow/snowplow', () => ({ Snowplow: { getInstance: jest.fn().mockReturnValue({ trackStructEvent: jest.fn() }) }, })); describe('LanguageServerStartupMonitor', () => { let monitor: LanguageServerStartupMonitor; let onDidChangeStateHandler: (event: StateChangeEvent) => void; let client: BaseLanguageClient; let trackStructEvent: jest.Mock; beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); trackStructEvent = jest.fn().mockResolvedValue(undefined); jest .mocked(Snowplow.getInstance) .mockReturnValue(createFakePartial<Snowplow>({ trackStructEvent })); const telemetryEnvironment = createFakePartial<GitLabTelemetryEnvironment>({ getOs: jest.fn().mockReturnValue('linux'), }); monitor = new LanguageServerStartupMonitor( telemetryEnvironment, new NoopObservabilityService(), ); monitor = new LanguageServerStartupMonitor(new NoopObservabilityService()); client = createFakePartial<BaseLanguageClient>({ onDidChangeState: jest.fn(handler => { onDidChangeStateHandler = handler; Loading Loading @@ -144,28 +128,6 @@ describe('LanguageServerStartupMonitor', () => { await expect(promise).resolves.toBeUndefined(); expect(monitor.phase).toBe('running'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ category: 'language_server', action: 'startup_completed', property: 'success', }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'success', failed_phase: null, os: 'linux', remote_name: null, }), }), }), 'ide-extension-context', ]), ); }); it('does not reject when State.Stopped fires - lets the library handle restarts', async () => { Loading @@ -186,22 +148,6 @@ describe('LanguageServerStartupMonitor', () => { await expect(promise).rejects.toThrow('Language Server process did not start within 10s'); expect(monitor.phase).toBe('failed'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'idle', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('clears spawn timeout when State.Starting fires', async () => { Loading @@ -224,22 +170,6 @@ describe('LanguageServerStartupMonitor', () => { 'Language Server process started but LSP initialize handshake did not complete within 30s', ); expect(monitor.phase).toBe('failed'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'spawning', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('clears handshake timeout when State.Running fires', async () => { Loading Loading @@ -288,22 +218,6 @@ describe('LanguageServerStartupMonitor', () => { expect(log.debug).toHaveBeenCalledWith( expect.stringContaining('failed to restart after 1 attempt(s)'), ); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'spawning', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('does not reject when State.Starting follows State.Stopped within grace period', async () => { Loading src/common/language_server/language_server_startup_monitor.ts +6 −60 Original line number Diff line number Diff line Loading @@ -2,10 +2,6 @@ import { BaseLanguageClient, Disposable, State } from 'vscode-languageclient'; import * as vscode from 'vscode'; import type { Histogram, Span, Tracer } from '@opentelemetry/api'; import { log } from '../log'; import { Snowplow } from '../snowplow/snowplow'; import { EXTENSION_EVENT_SOURCE, GITLAB_STANDARD_SCHEMA_URL } from '../snowplow/snowplow_options'; import { GitLabEnvironment } from '../snowplow/get_environment'; import { GitLabTelemetryEnvironment } from '../platform/gitlab_telemetry_environment'; import type { ObservabilityService } from '../observability'; export type StartupPhase = 'idle' | 'spawning' | 'handshake' | 'running' | 'failed'; Loading @@ -15,8 +11,6 @@ const HANDSHAKE_TIMEOUT_MS = 30000; const RESTART_GRACE_PERIOD_MS = 5000; export class LanguageServerStartupMonitor { #telemetryEnvironment: GitLabTelemetryEnvironment; #phase: StartupPhase = 'idle'; #tProcessStarting = 0; Loading @@ -43,11 +37,7 @@ export class LanguageServerStartupMonitor { #histogram: Histogram | null; constructor( telemetryEnvironment: GitLabTelemetryEnvironment, observabilityService: ObservabilityService, ) { this.#telemetryEnvironment = telemetryEnvironment; constructor(observabilityService: ObservabilityService) { this.#tracer = observabilityService.getTracer('gitlab-vscode-extension.language-server'); this.#histogram = observabilityService Loading Loading @@ -83,55 +73,19 @@ export class LanguageServerStartupMonitor { this.#setPhase('handshake'); } #trackStartupTelemetry({ #recordStartupMetrics({ status, failedPhase, spawnMs, handshakeMs, totalMs, }: { status: 'success' | 'failed'; failedPhase: StartupPhase | null; spawnMs: number; handshakeMs: number; totalMs: number; }): void { const context = { schema: GITLAB_STANDARD_SCHEMA_URL, data: { source: EXTENSION_EVENT_SOURCE, environment: GitLabEnvironment.GITLAB_SELF_MANAGED, extra: { status, failed_phase: failedPhase, spawn_duration_ms: Math.round(spawnMs), handshake_duration_ms: Math.round(handshakeMs), total_duration_ms: Math.round(totalMs), restart_attempts: this.#restartAttempts, os: this.#telemetryEnvironment.getOs(), remote_name: vscode.env.remoteName ?? null, }, }, }; this.#histogram?.record(Math.round(totalMs), { status, failed_phase: failedPhase ?? '', }); setTimeout(() => { Snowplow.getInstance() .trackStructEvent( { category: 'language_server', action: 'startup_completed', label: 'status', property: status, }, [context, 'ide-extension-context'], ) .catch(err => log.warn('[LS startup] Failed to track startup telemetry', err)); }, 10000); } observe(client: BaseLanguageClient): Promise<void> { Loading @@ -156,11 +110,9 @@ export class LanguageServerStartupMonitor { }); this.#startupSpan?.end(); this.#startupSpan = undefined; this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase: 'idle', spawnMs: 0, handshakeMs: 0, totalMs: SPAWN_TIMEOUT_MS, }); fail( Loading Loading @@ -198,11 +150,9 @@ export class LanguageServerStartupMonitor { this.#startupSpan?.end(); this.#startupSpan = undefined; this.#setPhase('failed'); this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase, spawnMs, handshakeMs: HANDSHAKE_TIMEOUT_MS, totalMs: spawnMs + HANDSHAKE_TIMEOUT_MS, }); fail( Loading Loading @@ -232,11 +182,9 @@ export class LanguageServerStartupMonitor { ); clearTimeout(this.#handshakeTimeout); this.#handshakeTimeout = undefined; this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'success', failedPhase: null, spawnMs, handshakeMs, totalMs, }); resolve(); Loading @@ -262,11 +210,9 @@ export class LanguageServerStartupMonitor { this.#startupSpan?.end(); this.#startupSpan = undefined; this.#setPhase('failed'); this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase: 'spawning', spawnMs: 0, handshakeMs: 0, totalMs: RESTART_GRACE_PERIOD_MS, }); log.debug( Loading Loading
src/common/language_server/language_client_wrapper.test.ts +0 −8 Original line number Diff line number Diff line Loading @@ -46,13 +46,6 @@ import { LanguageServerStartupMonitor } from './language_server_startup_monitor' jest.mock('../code_suggestions/gitlab_platform_manager_for_code_suggestions'); jest.mock('../log'); // disable logging in tests jest.mock('../feature_flags/local_feature_flag_service'); jest.mock('../snowplow/snowplow', () => ({ Snowplow: { getInstance: jest .fn() .mockReturnValue({ trackStructEvent: jest.fn().mockResolvedValue(undefined) }), }, })); jest.mock('../webview'); Loading Loading @@ -123,7 +116,6 @@ describe('LanguageClientWrapper', () => { const createWrapperWithMonitor = (options: Parameters<typeof createWrapper>[0] = {}) => { const wrapperWithMonitor = createWrapper(options); wrapperWithMonitor.startupMonitor = new LanguageServerStartupMonitor( options.mockTelemetryEnvironment ?? gitLabTelemetryEnvironment, new NoopObservabilityService(), ); return wrapperWithMonitor; Loading
src/common/language_server/language_server_manager.ts +1 −4 Original line number Diff line number Diff line Loading @@ -112,10 +112,7 @@ export class LanguageServerManager implements WebviewManager, VersionProvider { ); const gutterIcon = new CodeSuggestionsGutterIcon(this.#context, stateManager); const middleware = new LanguageClientMiddleware(stateManager); const startupMonitor = new LanguageServerStartupMonitor( gitLabTelemetryEnvironment, observabilityService, ); const startupMonitor = new LanguageServerStartupMonitor(observabilityService); const statusBarItem = new DuoStatusBarItem(stateManager, startupMonitor); const startupStatusBarItem = new LanguageServerStartupStatusBarItem(startupMonitor); const baseAssetsUrl = vscode.Uri.joinPath( Loading
src/common/language_server/language_server_startup_monitor.test.ts +1 −87 Original line number Diff line number Diff line import { BaseLanguageClient, State, StateChangeEvent } from 'vscode-languageclient'; import { createFakePartial } from '../test_utils/create_fake_partial'; import { log } from '../log'; import { Snowplow } from '../snowplow/snowplow'; import { GitLabTelemetryEnvironment } from '../platform/gitlab_telemetry_environment'; import { NoopObservabilityService } from '../observability'; import { LanguageServerStartupMonitor } from './language_server_startup_monitor'; jest.mock('../log'); jest.mock('../snowplow/snowplow', () => ({ Snowplow: { getInstance: jest.fn().mockReturnValue({ trackStructEvent: jest.fn() }) }, })); describe('LanguageServerStartupMonitor', () => { let monitor: LanguageServerStartupMonitor; let onDidChangeStateHandler: (event: StateChangeEvent) => void; let client: BaseLanguageClient; let trackStructEvent: jest.Mock; beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); trackStructEvent = jest.fn().mockResolvedValue(undefined); jest .mocked(Snowplow.getInstance) .mockReturnValue(createFakePartial<Snowplow>({ trackStructEvent })); const telemetryEnvironment = createFakePartial<GitLabTelemetryEnvironment>({ getOs: jest.fn().mockReturnValue('linux'), }); monitor = new LanguageServerStartupMonitor( telemetryEnvironment, new NoopObservabilityService(), ); monitor = new LanguageServerStartupMonitor(new NoopObservabilityService()); client = createFakePartial<BaseLanguageClient>({ onDidChangeState: jest.fn(handler => { onDidChangeStateHandler = handler; Loading Loading @@ -144,28 +128,6 @@ describe('LanguageServerStartupMonitor', () => { await expect(promise).resolves.toBeUndefined(); expect(monitor.phase).toBe('running'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ category: 'language_server', action: 'startup_completed', property: 'success', }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'success', failed_phase: null, os: 'linux', remote_name: null, }), }), }), 'ide-extension-context', ]), ); }); it('does not reject when State.Stopped fires - lets the library handle restarts', async () => { Loading @@ -186,22 +148,6 @@ describe('LanguageServerStartupMonitor', () => { await expect(promise).rejects.toThrow('Language Server process did not start within 10s'); expect(monitor.phase).toBe('failed'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'idle', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('clears spawn timeout when State.Starting fires', async () => { Loading @@ -224,22 +170,6 @@ describe('LanguageServerStartupMonitor', () => { 'Language Server process started but LSP initialize handshake did not complete within 30s', ); expect(monitor.phase).toBe('failed'); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'spawning', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('clears handshake timeout when State.Running fires', async () => { Loading Loading @@ -288,22 +218,6 @@ describe('LanguageServerStartupMonitor', () => { expect(log.debug).toHaveBeenCalledWith( expect.stringContaining('failed to restart after 1 attempt(s)'), ); jest.advanceTimersByTime(10000); expect(trackStructEvent).toHaveBeenCalledWith( expect.objectContaining({ property: 'failed' }), expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ extra: expect.objectContaining({ status: 'failed', failed_phase: 'spawning', os: 'linux', remote_name: null, }), }), }), ]), ); }); it('does not reject when State.Starting follows State.Stopped within grace period', async () => { Loading
src/common/language_server/language_server_startup_monitor.ts +6 −60 Original line number Diff line number Diff line Loading @@ -2,10 +2,6 @@ import { BaseLanguageClient, Disposable, State } from 'vscode-languageclient'; import * as vscode from 'vscode'; import type { Histogram, Span, Tracer } from '@opentelemetry/api'; import { log } from '../log'; import { Snowplow } from '../snowplow/snowplow'; import { EXTENSION_EVENT_SOURCE, GITLAB_STANDARD_SCHEMA_URL } from '../snowplow/snowplow_options'; import { GitLabEnvironment } from '../snowplow/get_environment'; import { GitLabTelemetryEnvironment } from '../platform/gitlab_telemetry_environment'; import type { ObservabilityService } from '../observability'; export type StartupPhase = 'idle' | 'spawning' | 'handshake' | 'running' | 'failed'; Loading @@ -15,8 +11,6 @@ const HANDSHAKE_TIMEOUT_MS = 30000; const RESTART_GRACE_PERIOD_MS = 5000; export class LanguageServerStartupMonitor { #telemetryEnvironment: GitLabTelemetryEnvironment; #phase: StartupPhase = 'idle'; #tProcessStarting = 0; Loading @@ -43,11 +37,7 @@ export class LanguageServerStartupMonitor { #histogram: Histogram | null; constructor( telemetryEnvironment: GitLabTelemetryEnvironment, observabilityService: ObservabilityService, ) { this.#telemetryEnvironment = telemetryEnvironment; constructor(observabilityService: ObservabilityService) { this.#tracer = observabilityService.getTracer('gitlab-vscode-extension.language-server'); this.#histogram = observabilityService Loading Loading @@ -83,55 +73,19 @@ export class LanguageServerStartupMonitor { this.#setPhase('handshake'); } #trackStartupTelemetry({ #recordStartupMetrics({ status, failedPhase, spawnMs, handshakeMs, totalMs, }: { status: 'success' | 'failed'; failedPhase: StartupPhase | null; spawnMs: number; handshakeMs: number; totalMs: number; }): void { const context = { schema: GITLAB_STANDARD_SCHEMA_URL, data: { source: EXTENSION_EVENT_SOURCE, environment: GitLabEnvironment.GITLAB_SELF_MANAGED, extra: { status, failed_phase: failedPhase, spawn_duration_ms: Math.round(spawnMs), handshake_duration_ms: Math.round(handshakeMs), total_duration_ms: Math.round(totalMs), restart_attempts: this.#restartAttempts, os: this.#telemetryEnvironment.getOs(), remote_name: vscode.env.remoteName ?? null, }, }, }; this.#histogram?.record(Math.round(totalMs), { status, failed_phase: failedPhase ?? '', }); setTimeout(() => { Snowplow.getInstance() .trackStructEvent( { category: 'language_server', action: 'startup_completed', label: 'status', property: status, }, [context, 'ide-extension-context'], ) .catch(err => log.warn('[LS startup] Failed to track startup telemetry', err)); }, 10000); } observe(client: BaseLanguageClient): Promise<void> { Loading @@ -156,11 +110,9 @@ export class LanguageServerStartupMonitor { }); this.#startupSpan?.end(); this.#startupSpan = undefined; this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase: 'idle', spawnMs: 0, handshakeMs: 0, totalMs: SPAWN_TIMEOUT_MS, }); fail( Loading Loading @@ -198,11 +150,9 @@ export class LanguageServerStartupMonitor { this.#startupSpan?.end(); this.#startupSpan = undefined; this.#setPhase('failed'); this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase, spawnMs, handshakeMs: HANDSHAKE_TIMEOUT_MS, totalMs: spawnMs + HANDSHAKE_TIMEOUT_MS, }); fail( Loading Loading @@ -232,11 +182,9 @@ export class LanguageServerStartupMonitor { ); clearTimeout(this.#handshakeTimeout); this.#handshakeTimeout = undefined; this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'success', failedPhase: null, spawnMs, handshakeMs, totalMs, }); resolve(); Loading @@ -262,11 +210,9 @@ export class LanguageServerStartupMonitor { this.#startupSpan?.end(); this.#startupSpan = undefined; this.#setPhase('failed'); this.#trackStartupTelemetry({ this.#recordStartupMetrics({ status: 'failed', failedPhase: 'spawning', spawnMs: 0, handshakeMs: 0, totalMs: RESTART_GRACE_PERIOD_MS, }); log.debug( Loading