internal: (studio) capture anonymous telemetry for studio initialization (#31555)

This commit is contained in:
Adam Stone-Lord
2025-04-30 09:18:59 -04:00
committed by GitHub
parent 9f86cecdd7
commit 2a6e9fbd44
8 changed files with 118 additions and 9 deletions
+16 -3
View File
@@ -1,4 +1,4 @@
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent } from '@packages/types'
import type { Router } from 'express'
import type { Socket } from 'socket.io'
import Debug from 'debug'
@@ -61,6 +61,15 @@ export class StudioManager implements StudioManagerShape {
}
}
async captureStudioEvent (event: StudioEvent): Promise<void> {
if (this._studioServer) {
// this request is not essential - we don't want studio to error out if a telemetry request fails
return (await this.invokeAsync('captureStudioEvent', { isEssential: false }, event))
}
return Promise.resolve()
}
addSocketListeners (socket: Socket): void {
if (this._studioServer) {
this.invokeSync('addSocketListeners', { isEssential: true }, socket)
@@ -119,7 +128,7 @@ export class StudioManager implements StudioManagerShape {
}
/**
* Abstracts invoking a synchronous method on the StudioServer instance, so we can handle
* Abstracts invoking an asynchronous method on the StudioServer instance, so we can handle
* errors in a uniform way
*/
private async invokeAsync <K extends StudioServerAsyncMethods> (method: K, { isEssential }: { isEssential: boolean }, ...args: Parameters<StudioServerShape[K]>): Promise<ReturnType<StudioServerShape[K]> | undefined> {
@@ -139,7 +148,11 @@ export class StudioManager implements StudioManagerShape {
actualError = error
}
this.status = 'IN_ERROR'
// only set error state if this request is essential
if (isEssential) {
this.status = 'IN_ERROR'
}
this.reportError(actualError, method, ...args)
return undefined
+18 -1
View File
@@ -17,7 +17,7 @@ import { SocketCt } from './socket-ct'
import { SocketE2E } from './socket-e2e'
import { ensureProp } from './util/class-helpers'
import system from './util/system'
import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands } from '@packages/types'
import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
import { createHmac } from 'crypto'
import { ServerBase } from './server-base'
@@ -431,6 +431,23 @@ export class ProjectBase extends EE {
const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()
try {
studio?.captureStudioEvent({
type: StudioMetricsTypes.STUDIO_STARTED,
machineId: await this.ctx.coreData.machineId,
projectId: this.cfg.projectId,
browser: this.browser ? {
name: this.browser.name,
family: this.browser.family,
channel: this.browser.channel,
version: this.browser.version,
} : undefined,
cypressVersion: pkg.version,
})
} catch (error) {
debug('Error capturing studio event:', error)
}
if (this.spec && studio?.protocolManager) {
const canAccessStudioAI = await studio?.canAccessStudioAI(this.browser) ?? false
@@ -28,6 +28,10 @@ class StudioServer implements StudioServerShape {
addSocketListeners (socket: Socket): void {
// This is a test implementation that does nothing
}
captureStudioEvent (event: StudioEvent): Promise<void> {
return Promise.resolve()
}
}
const studioServerDefault: StudioServerDefaultShape = {
@@ -75,6 +75,16 @@ describe('lib/cloud/studio', () => {
expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(error, 'canAccessStudioAI', {})
})
it('does not set state IN_ERROR when a non-essential async method fails', async () => {
const error = new Error('foo')
sinon.stub(studio, 'captureStudioEvent').throws(error)
await studioManager.captureStudioEvent({} as any)
expect(studioManager.status).to.eq('ENABLED')
})
})
describe('createInErrorManager', () => {
+46 -4
View File
@@ -661,6 +661,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const mockSetupProtocol = sinon.stub()
const mockBeforeSpec = sinon.stub()
const mockAccessStudioAI = sinon.stub().resolves(true)
const mockCaptureStudioEvent = sinon.stub().resolves()
this.project.spec = {}
@@ -672,6 +673,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const studioManager = new StudioManager()
studioManager.canAccessStudioAI = mockAccessStudioAI
studioManager.captureStudioEvent = mockCaptureStudioEvent
studioManager.protocolManager = {
setupProtocol: mockSetupProtocol,
beforeSpec: mockBeforeSpec,
@@ -715,6 +717,18 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const { canAccessStudioAI } = await studioInitPromise
expect(canAccessStudioAI).to.be.true
expect(mockCaptureStudioEvent).to.be.calledWith({
type: 'studio:started',
machineId: 'test-machine-id',
projectId: 'test-project-id',
browser: {
name: 'chrome',
family: 'chromium',
channel: undefined,
version: undefined,
},
cypressVersion: pkg.version,
})
expect(mockSetupProtocol).to.be.calledOnce
expect(mockBeforeSpec).to.be.calledOnce
@@ -736,6 +750,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const mockSetupProtocol = sinon.stub()
const mockBeforeSpec = sinon.stub()
const mockAccessStudioAI = sinon.stub().resolves(true)
const mockCaptureStudioEvent = sinon.stub().resolves()
this.project.spec = {}
@@ -747,14 +762,15 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const studioManager = new StudioManager()
studioManager.canAccessStudioAI = mockAccessStudioAI
studioManager.captureStudioEvent = mockCaptureStudioEvent
const studioLifecycleManager = new StudioLifecycleManager()
this.project.ctx.coreData.studioLifecycleManager = studioLifecycleManager
// Set up the studio manager promise directly
studioLifecycleManager.studioManagerPromise = Promise.resolve(studioManager)
studioLifecycleManager.isStudioReady = sinon.stub().returns(true)
// Create a browser object
this.project.browser = {
name: 'chrome',
@@ -783,6 +799,18 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const { canAccessStudioAI } = await studioInitPromise
expect(canAccessStudioAI).to.be.false
expect(mockCaptureStudioEvent).to.be.calledWith({
type: 'studio:started',
machineId: 'test-machine-id',
projectId: 'test-project-id',
browser: {
name: 'chrome',
family: 'chromium',
channel: undefined,
version: undefined,
},
cypressVersion: pkg.version,
})
expect(mockSetupProtocol).not.to.be.called
expect(mockBeforeSpec).not.to.be.called
@@ -796,6 +824,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const mockSetupProtocol = sinon.stub()
const mockBeforeSpec = sinon.stub()
const mockAccessStudioAI = sinon.stub().resolves(false)
const mockCaptureStudioEvent = sinon.stub().resolves()
this.project.spec = {}
@@ -807,6 +836,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const studioManager = new StudioManager()
studioManager.canAccessStudioAI = mockAccessStudioAI
studioManager.captureStudioEvent = mockCaptureStudioEvent
studioManager.protocolManager = {
setupProtocol: mockSetupProtocol,
beforeSpec: mockBeforeSpec,
@@ -816,10 +846,10 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
this.project.ctx.coreData.studioLifecycleManager = studioLifecycleManager
// Set up the studio manager promise directly
studioLifecycleManager.studioManagerPromise = Promise.resolve(studioManager)
// Create a browser object
studioLifecycleManager.isStudioReady = sinon.stub().returns(true)
this.project.browser = {
name: 'chrome',
family: 'chromium',
@@ -847,6 +877,18 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
const { canAccessStudioAI } = await studioInitPromise
expect(canAccessStudioAI).to.be.false
expect(mockCaptureStudioEvent).to.be.calledWith({
type: 'studio:started',
machineId: 'test-machine-id',
projectId: 'test-project-id',
browser: {
name: 'chrome',
family: 'chromium',
channel: undefined,
version: undefined,
},
cypressVersion: pkg.version,
})
expect(mockSetupProtocol).not.to.be.called
expect(mockBeforeSpec).not.to.be.called
+1
View File
@@ -242,6 +242,7 @@ describe('lib/routes', () => {
status: 'INITIALIZED',
initializeRoutes: sinon.stub(),
isProtocolEnabled: false,
captureStudioEvent: sinon.stub(),
canAccessStudioAI: sinon.stub(),
setProtocolDb: sinon.stub(),
addSocketListeners: sinon.stub(),
+2 -1
View File
@@ -1,5 +1,5 @@
import type { ProtocolManagerShape } from '../protocol'
import type { StudioServerShape } from './studio-server-types'
import type { StudioServerShape, StudioEvent } from './studio-server-types'
export * from './studio-server-types'
@@ -11,6 +11,7 @@ export interface StudioManagerShape extends StudioServerShape {
status: StudioStatus
isProtocolEnabled: boolean
protocolManager?: ProtocolManagerShape
captureStudioEvent: (event: StudioEvent) => Promise<void>
}
export interface StudioLifecycleManagerShape {
@@ -4,6 +4,26 @@ import type { Router } from 'express'
import type { AxiosInstance } from 'axios'
import type { Socket } from 'socket.io'
export const StudioMetricsTypes = {
STUDIO_STARTED: 'studio:started',
} as const
export type StudioMetricsType =
(typeof StudioMetricsTypes)[keyof typeof StudioMetricsTypes]
export interface StudioEvent {
type: StudioMetricsType
machineId: string | null
projectId?: string
browser?: {
name: string
family: string
channel?: string
version?: string
}
cypressVersion?: string
}
interface RetryOptions {
maxAttempts: number
retryDelay?: (attempt: number) => number
@@ -47,6 +67,7 @@ export interface StudioServerShape {
...studioMethodArgs: unknown[]
): void
destroy(): Promise<void>
captureStudioEvent(event: StudioEvent): Promise<void>
}
export interface StudioServerDefaultShape {