Skip to content

Commit 38f980e

Browse files
internal: (studio) improve error reporting (#31546)
* internal: (studio) improvements to how studio can access the protocol database * fix * fix build * refactor due to downstream changes * feat: capture errors with studio * types * add tests * fix types * fix typescript * strip path * fix tests
1 parent 9697a86 commit 38f980e

14 files changed

+457
-148
lines changed

packages/server/lib/cloud/api/get_and_initialize_studio_manager.ts renamed to packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts

+27-13
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import path from 'path'
22
import os from 'os'
33
import { ensureDir, copy, readFile } from 'fs-extra'
4-
import { StudioManager } from '../studio'
4+
import { StudioManager } from '../../studio'
55
import tar from 'tar'
6-
import { verifySignatureFromFile } from '../encryption'
6+
import { verifySignatureFromFile } from '../../encryption'
77
import crypto from 'crypto'
88
import fs from 'fs'
99
import fetch from 'cross-fetch'
1010
import { agent } from '@packages/network'
11-
import { asyncRetry, linearDelay } from '../../util/async_retry'
12-
import { isRetryableError } from '../network/is_retryable_error'
13-
import { PUBLIC_KEY_VERSION } from '../constants'
14-
import { CloudRequest } from './cloud_request'
11+
import { asyncRetry, linearDelay } from '../../../util/async_retry'
12+
import { isRetryableError } from '../../network/is_retryable_error'
13+
import { PUBLIC_KEY_VERSION } from '../../constants'
14+
import { CloudRequest } from '../cloud_request'
1515
import type { CloudDataSource } from '@packages/data-context/src/sources'
1616

1717
const pkg = require('@packages/root')
18-
const routes = require('../routes')
18+
const routes = require('../../routes')
1919

2020
const _delay = linearDelay(500)
2121

@@ -121,17 +121,19 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
121121
export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource }: { projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
122122
let script: string
123123

124+
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
125+
const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv)
126+
const cloudHeaders = await cloudDataSource.additionalHeaders()
127+
128+
let studioHash: string | undefined
129+
124130
try {
125-
const { studioHash } = await retrieveAndExtractStudioBundle({ projectId })
131+
({ studioHash } = await retrieveAndExtractStudioBundle({ projectId }))
126132

127133
script = await readFile(serverFilePath, 'utf8')
128134

129135
const studioManager = new StudioManager()
130136

131-
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
132-
const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv)
133-
const cloudHeaders = await cloudDataSource.additionalHeaders()
134-
135137
await studioManager.setup({
136138
script,
137139
studioPath,
@@ -156,7 +158,19 @@ export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource
156158
actualError = error
157159
}
158160

159-
return StudioManager.createInErrorManager(actualError)
161+
return StudioManager.createInErrorManager({
162+
cloudApi: {
163+
cloudUrl,
164+
cloudHeaders,
165+
CloudRequest,
166+
isRetryableError,
167+
asyncRetry,
168+
},
169+
studioHash,
170+
projectSlug: projectId,
171+
error: actualError,
172+
studioMethod: 'getAndInitializeStudioManager',
173+
})
160174
} finally {
161175
await fs.promises.rm(bundlePath, { force: true })
162176
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { StudioCloudApi } from '@packages/types/src/studio/studio-server-types'
2+
import Debug from 'debug'
3+
import { stripPath } from '../../strip_path'
4+
5+
const debug = Debug('cypress:server:cloud:api:studio:report_studio_errors')
6+
7+
export interface ReportStudioErrorOptions {
8+
cloudApi: StudioCloudApi
9+
studioHash: string | undefined
10+
projectSlug: string | undefined
11+
error: unknown
12+
studioMethod: string
13+
studioMethodArgs?: unknown[]
14+
}
15+
16+
interface StudioError {
17+
name: string
18+
stack: string
19+
message: string
20+
studioMethod: string
21+
studioMethodArgs?: string
22+
}
23+
24+
interface StudioErrorPayload {
25+
studioHash: string | undefined
26+
projectSlug: string | undefined
27+
errors: StudioError[]
28+
}
29+
30+
export function reportStudioError ({
31+
cloudApi,
32+
studioHash,
33+
projectSlug,
34+
error,
35+
studioMethod,
36+
studioMethodArgs,
37+
}: ReportStudioErrorOptions): void {
38+
debug('Error reported:', error)
39+
40+
// When developing locally, we want to throw the error so we can see it in the console
41+
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
42+
throw error
43+
}
44+
45+
let errorObject: Error
46+
47+
if (!(error instanceof Error)) {
48+
errorObject = new Error(String(error))
49+
} else {
50+
errorObject = error
51+
}
52+
53+
let studioMethodArgsString: string | undefined
54+
55+
if (studioMethodArgs) {
56+
try {
57+
studioMethodArgsString = JSON.stringify({
58+
args: studioMethodArgs,
59+
})
60+
} catch (e: unknown) {
61+
studioMethodArgsString = `Unknown args: ${e}`
62+
}
63+
}
64+
65+
try {
66+
const payload: StudioErrorPayload = {
67+
studioHash,
68+
projectSlug,
69+
errors: [{
70+
name: stripPath(errorObject.name ?? `Unknown name`),
71+
stack: stripPath(errorObject.stack ?? `Unknown stack`),
72+
message: stripPath(errorObject.message ?? `Unknown message`),
73+
studioMethod,
74+
studioMethodArgs: studioMethodArgsString,
75+
}],
76+
}
77+
78+
cloudApi.CloudRequest.post(
79+
`${cloudApi.cloudUrl}/studio/errors`,
80+
payload,
81+
{
82+
headers: {
83+
'Content-Type': 'application/json',
84+
...cloudApi.cloudHeaders,
85+
},
86+
},
87+
).catch((e: unknown) => {
88+
debug(
89+
`Error calling StudioManager.reportError: %o, original error %o`,
90+
e,
91+
error,
92+
)
93+
})
94+
} catch (e: unknown) {
95+
debug(
96+
`Error calling StudioManager.reportError: %o, original error %o`,
97+
e,
98+
error,
99+
)
100+
}
101+
}

packages/server/lib/cloud/exception.ts

+1-12
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,7 @@ const pkg = require('@packages/root')
44
const api = require('./api').default
55
const user = require('./user')
66
const system = require('../util/system')
7-
8-
// strip everything but the file name to remove any sensitive
9-
// data in the path
10-
const pathRe = /'?((\/|\\+|[a-z]:\\)[^\s']+)+'?/ig
11-
const pathSepRe = /[\/\\]+/
12-
const stripPath = (text) => {
13-
return (text || '').replace(pathRe, (path) => {
14-
const fileName = _.last(path.split(pathSepRe)) || ''
15-
16-
return `<stripped-path>${fileName}`
17-
})
18-
}
7+
const { stripPath } = require('./strip_path')
198

209
export = {
2110
getErr (err: Error) {
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { last } from 'lodash'
2+
3+
// strip everything but the file name to remove any sensitive
4+
// data in the path
5+
const pathRe = /'?((\/|\\+|[a-z]:\\)[^\s']+)+'?/ig
6+
const pathSepRe = /[\/\\]+/
7+
8+
export const stripPath = (text: string) => {
9+
return (text || '').replace(pathRe, (path) => {
10+
const fileName = last(path.split(pathSepRe)) || ''
11+
12+
return `<stripped-path>${fileName}`
13+
})
14+
}

packages/server/lib/cloud/studio.ts

+17-39
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import type { StudioErrorReport, StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
1+
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
22
import type { Router } from 'express'
33
import type { Socket } from 'socket.io'
4-
import fetch from 'cross-fetch'
5-
import pkg from '@packages/root'
6-
import os from 'os'
7-
import { agent } from '@packages/network'
84
import Debug from 'debug'
95
import { requireScript } from './require_script'
106
import path from 'path'
7+
import { reportStudioError, ReportStudioErrorOptions } from './api/studio/report_studio_error'
118

129
interface StudioServer { default: StudioServerDefaultShape }
1310

@@ -20,21 +17,26 @@ interface SetupOptions {
2017
}
2118

2219
const debug = Debug('cypress:server:studio')
23-
const routes = require('./routes')
2420

2521
export class StudioManager implements StudioManagerShape {
2622
status: StudioStatus = 'NOT_INITIALIZED'
2723
isProtocolEnabled: boolean = false
2824
protocolManager: ProtocolManagerShape | undefined
2925
private _studioServer: StudioServerShape | undefined
30-
private _studioHash: string | undefined
3126

32-
static createInErrorManager (error: Error): StudioManager {
27+
static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager {
3328
const manager = new StudioManager()
3429

3530
manager.status = 'IN_ERROR'
3631

37-
manager.reportError(error).catch(() => { })
32+
reportStudioError({
33+
cloudApi,
34+
studioHash,
35+
projectSlug,
36+
error,
37+
studioMethod,
38+
studioMethodArgs,
39+
})
3840

3941
return manager
4042
}
@@ -43,13 +45,13 @@ export class StudioManager implements StudioManagerShape {
4345
const { createStudioServer } = requireScript<StudioServer>(script).default
4446

4547
this._studioServer = await createStudioServer({
48+
studioHash,
4649
studioPath,
4750
projectSlug,
4851
cloudApi,
4952
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
5053
})
5154

52-
this._studioHash = studioHash
5355
this.status = 'INITIALIZED'
5456
}
5557

@@ -77,32 +79,11 @@ export class StudioManager implements StudioManagerShape {
7779
await this.invokeAsync('destroy', { isEssential: true })
7880
}
7981

80-
private async reportError (error: Error): Promise<void> {
82+
reportError (error: unknown, studioMethod: string, ...studioMethodArgs: unknown[]): void {
8183
try {
82-
const payload: StudioErrorReport = {
83-
studioHash: this._studioHash,
84-
errors: [{
85-
name: error.name ?? `Unknown name`,
86-
stack: error.stack ?? `Unknown stack`,
87-
message: error.message ?? `Unknown message`,
88-
}],
89-
}
90-
91-
const body = JSON.stringify(payload)
92-
93-
await fetch(routes.apiRoutes.studioErrors() as string, {
94-
// @ts-expect-error - this is supported
95-
agent,
96-
method: 'POST',
97-
body,
98-
headers: {
99-
'Content-Type': 'application/json',
100-
'x-cypress-version': pkg.version,
101-
'x-os-name': os.platform(),
102-
'x-arch': os.arch(),
103-
},
104-
})
84+
this._studioServer?.reportError(error, studioMethod, ...studioMethodArgs)
10585
} catch (e) {
86+
// If we fail to report the error, we shouldn't try and report it again
10687
debug(`Error calling StudioManager.reportError: %o, original error %o`, e, error)
10788
}
10889
}
@@ -129,8 +110,7 @@ export class StudioManager implements StudioManagerShape {
129110
}
130111

131112
this.status = 'IN_ERROR'
132-
// Call and forget this, we don't want to block the main thread
133-
this.reportError(actualError).catch(() => { })
113+
this.reportError(actualError, method, ...args)
134114
}
135115
}
136116

@@ -156,10 +136,8 @@ export class StudioManager implements StudioManagerShape {
156136
}
157137

158138
this.status = 'IN_ERROR'
159-
// Call and forget this, we don't want to block the main thread
160-
this.reportError(actualError).catch(() => { })
139+
this.reportError(actualError, method, ...args)
161140

162-
// TODO: Figure out errors
163141
return undefined
164142
}
165143
}

packages/server/lib/project-base.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import ProtocolManager from './cloud/protocol'
2525
import { ServerBase } from './server-base'
2626
import type Protocol from 'devtools-protocol'
2727
import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager'
28-
import { getAndInitializeStudioManager } from './cloud/api/get_and_initialize_studio_manager'
28+
import { getAndInitializeStudioManager } from './cloud/api/studio/get_and_initialize_studio_manager'
2929
import api from './cloud/api'
3030
import type { StudioManager } from './cloud/studio'
3131
import { v4 } from 'uuid'

packages/server/test/support/fixtures/cloud/studio/test-studio.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ class StudioServer implements StudioServerShape {
1717
return Promise.resolve()
1818
}
1919

20+
reportError (error: Error, method: string, ...args: any[]): void {
21+
// This is a test implementation that does nothing
22+
}
23+
2024
destroy (): Promise<void> {
2125
return Promise.resolve()
2226
}
23-
27+
2428
addSocketListeners (socket: Socket): void {
2529
// This is a test implementation that does nothing
2630
}

0 commit comments

Comments
 (0)