From eb7898e683e360289571134aecf4bf0253193f38 Mon Sep 17 00:00:00 2001 From: "cypress-bot[bot]" <+cypress-bot[bot]@users.noreply.github.com> Date: Thu, 18 May 2023 00:35:33 +0000 Subject: [PATCH] chore: updating v8 snapshot cache --- .github/workflows/triage_add_to_project.yml | 21 +- .../workflows/triage_handle_new_comments.yml | 2 +- cli/CHANGELOG.md | 1 + packages/app/src/pages/Debug.vue | 3 +- packages/app/src/runner/events/telemetry.ts | 65 +++++ .../src/sources/HtmlDataSource.ts | 2 +- packages/driver/src/cypress.ts | 3 + packages/driver/src/cypress/command_queue.ts | 1 + .../lib/server/middleware/request.ts | 12 + .../lib/server/middleware/response.ts | 1 - packages/proxy/lib/http/index.ts | 53 +++- packages/proxy/lib/http/request-middleware.ts | 244 +++++++++++++++++- .../proxy/lib/http/response-middleware.ts | 162 ++++++++++-- packages/proxy/lib/network-proxy.ts | 16 +- packages/proxy/test/unit/http/index.spec.ts | 10 +- .../test/unit/http/request-middleware.spec.ts | 2 +- packages/server/lib/plugins/child/ts_node.js | 2 + .../test/unit/plugins/child/ts_node_spec.js | 4 + packages/telemetry/README.md | 4 +- packages/telemetry/index.js | 5 + packages/telemetry/package.json | 1 + packages/telemetry/src/browser.ts | 7 +- .../detectors/githubActionsDetectorSync.ts | 31 +++ packages/telemetry/src/index.ts | 145 ++++++++--- packages/telemetry/src/node.ts | 29 ++- .../src/processors/on-start-span-processor.ts | 15 ++ .../console-trace-link-exporter.ts | 77 ++++++ packages/telemetry/test/browser.spec.ts | 15 +- .../githubActionsDetectorSync.spec.ts | 97 +++++++ packages/telemetry/test/index.spec.ts | 68 ++++- packages/telemetry/test/node.spec.ts | 10 +- .../on-start-span-processor.spec.ts | 19 ++ .../console-trace-link-exporter.spec.ts | 168 ++++++++++++ .../cache/linux/snapshot-meta.json | 2 + 34 files changed, 1177 insertions(+), 120 deletions(-) create mode 100644 packages/telemetry/index.js create mode 100644 packages/telemetry/src/detectors/githubActionsDetectorSync.ts create mode 100644 packages/telemetry/src/processors/on-start-span-processor.ts create mode 100644 packages/telemetry/src/span-exporters/console-trace-link-exporter.ts create mode 100644 packages/telemetry/test/detectors/githubActionsDetectorSync.spec.ts create mode 100644 packages/telemetry/test/processors/on-start-span-processor.spec.ts create mode 100644 packages/telemetry/test/span-exporters/console-trace-link-exporter.spec.ts diff --git a/.github/workflows/triage_add_to_project.yml b/.github/workflows/triage_add_to_project.yml index 7cb4b1058515..841d9d3cb58b 100644 --- a/.github/workflows/triage_add_to_project.yml +++ b/.github/workflows/triage_add_to_project.yml @@ -41,5 +41,24 @@ jobs: with: project-url: https://github.com/orgs/${{github.repository_owner}}/projects/${{env.PROJECT_NUMBER}} github-token: ${{ secrets.ADD_TO_TRIAGE_BOARD_TOKEN }} - + add-contributor-pr-comment: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + repository: 'cypress-io/release-automations' + ref: 'master' + ssh-key: ${{ secrets.WORKFLOW_DEPLOY_KEY }} + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 'lts/*' + - name: Run comment_workflow.js Script + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.TRIAGE_BOARD_TOKEN }} + script: | + const script = require('./scripts/triage/add_contributing_comment.js') + await script.addContributingComment(github, context); diff --git a/.github/workflows/triage_handle_new_comments.yml b/.github/workflows/triage_handle_new_comments.yml index f2b2dbce4ac9..0cf253ad0d24 100644 --- a/.github/workflows/triage_handle_new_comments.yml +++ b/.github/workflows/triage_handle_new_comments.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: '16' + node-version: 'lts/*' - name: Run comment_workflow.js Script uses: actions/github-script@v6 with: diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 58154983ff49..fc6441362e3f 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -8,6 +8,7 @@ _Released 05/23/2023 (PENDING)_ - Reverted [#26452](https://github.com/cypress-io/cypress/pull/26630) which introduced a bug that prevents users from using End to End with Yarn 3. Fixed in [#26735](https://github.com/cypress-io/cypress/pull/26735). Fixes [#26676](https://github.com/cypress-io/cypress/issues/26676). - Moved `types` condition to the front of `package.json#exports` since keys there are meant to be order-sensitive. Fixed in [#26630](https://github.com/cypress-io/cypress/pull/26630). - Fixed an issue where newly-installed dependencies would not be detected during Component Testing setup. Addresses [#26685](https://github.com/cypress-io/cypress/issues/26685). +- Fixed a UI regression that was flashing an "empty" state inappropriately when loading the Debug page. Fixed in [#26761](https://github.com/cypress-io/cypress/pull/26761). **Misc:** diff --git a/packages/app/src/pages/Debug.vue b/packages/app/src/pages/Debug.vue index 4df2fd211d93..f3ae1a2a8197 100644 --- a/packages/app/src/pages/Debug.vue +++ b/packages/app/src/pages/Debug.vue @@ -58,7 +58,7 @@ const cachedProject = ref() const query = useQuery({ query: DebugDocument, variables, pause: shouldPauseQuery, requestPolicy: 'network-only' }) const isLoading = computed(() => { - const relevantRunsHaveNotLoaded = !relevantRuns.value + const relevantRunsHaveNotLoaded = !relevantRuns.value.all const queryIsBeingFetched = query.fetching.value const cloudProject = query.data.value?.currentProject?.cloudProject?.__typename === 'CloudProject' @@ -76,7 +76,6 @@ const isLoading = computed(() => { }) watchEffect(() => { - //console.log('query for debug', query.data.value, relevantRuns.value.commitShas) if (query.data.value?.currentProject?.cloudProject?.__typename === 'CloudProject') { const cloudProject = query.data.value.currentProject.cloudProject diff --git a/packages/app/src/runner/events/telemetry.ts b/packages/app/src/runner/events/telemetry.ts index 37088c959b72..94311bfbe797 100644 --- a/packages/app/src/runner/events/telemetry.ts +++ b/packages/app/src/runner/events/telemetry.ts @@ -36,4 +36,69 @@ export const addTelemetryListeners = (Cypress) => { // TODO: log error when client side debug logging is available } }) + + const commandSpanInfo = (command: Cypress.CommandQueue) => { + const runnable = Cypress.state('runnable') + const runnableType = runnable.type === 'hook' ? runnable.hookName : runnable.type + + return { + name: `${runnableType}: ${command.attributes.name}(${command.attributes.args.join(',')})`, + runnable, + runnableType, + } + } + + Cypress.on('command:start', (command: Cypress.CommandQueue) => { + try { + const test = Cypress.state('test') + + const { name, runnable, runnableType } = commandSpanInfo(command) + + const span = telemetry.startSpan({ + name, + opts: { + attributes: { + spec: runnable.invocationDetails.relativeFile, + test: `test:${test.fullTitle()}`, + 'runnable-type': runnableType, + }, + }, + isVerbose: true, + }) + + span?.setAttribute('command-name', command.attributes.name) + } catch (error) { + // TODO: log error when client side debug logging is available + } + }) + + const onCommandEnd = (command: Cypress.CommandQueue) => { + try { + const span = telemetry.getSpan(commandSpanInfo(command).name) + + span?.setAttribute('state', command.state) + span?.setAttribute('numLogs', command.logs?.length || 0) + span?.end() + } catch (error) { + // TODO: log error when client side debug logging is available + } + } + + Cypress.on('command:end', onCommandEnd) + + Cypress.on('skipped:command:end', onCommandEnd) + + Cypress.on('command:failed', (command: Cypress.CommandQueue, error: Error) => { + try { + const span = telemetry.getSpan(commandSpanInfo(command).name) + + span?.setAttribute('state', command.state) + span?.setAttribute('numLogs', command.logs?.length || 0) + span?.setAttribute('error.name', error.name) + span?.setAttribute('error.message', error.message) + span?.end() + } catch (error) { + // TODO: log error when client side debug logging is available + } + }) } diff --git a/packages/data-context/src/sources/HtmlDataSource.ts b/packages/data-context/src/sources/HtmlDataSource.ts index a8f377e2f1ea..bbdc351245a7 100644 --- a/packages/data-context/src/sources/HtmlDataSource.ts +++ b/packages/data-context/src/sources/HtmlDataSource.ts @@ -120,7 +120,7 @@ export class HtmlDataSource { window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)}; window.__CYPRESS_TESTING_TYPE__ = '${this.ctx.coreData.currentTestingType}' window.__CYPRESS_BROWSER__ = ${JSON.stringify(this.ctx.coreData.activeBrowser)} - ${telemetry.isEnabled() ? `window.__CYPRESS_TELEMETRY__ = ${JSON.stringify({ context: telemetry.getActiveContextObject(), resources: telemetry.getResources() })}` : ''} + ${telemetry.isEnabled() ? `window.__CYPRESS_TELEMETRY__ = ${JSON.stringify({ context: telemetry.getActiveContextObject(), resources: telemetry.getResources(), isVerbose: telemetry.isVerbose() })}` : ''} ${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET ? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';` : ''} `) diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 9850d6d92ca8..1afd7f2b9444 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -616,6 +616,9 @@ class $Cypress { case 'cy:command:end': return this.emit('command:end', ...args) + case 'cy:command:failed': + return this.emit('command:failed', ...args) + case 'cy:skipped:command:end': return this.emit('skipped:command:end', ...args) diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index f442657f63d9..be28d47858cb 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -541,6 +541,7 @@ export class CommandQueue extends Queue<$Command> { current.fail() } + Cypress.action('cy:command:failed', current, err) this.cleanup() return this.cy.fail(err) diff --git a/packages/net-stubbing/lib/server/middleware/request.ts b/packages/net-stubbing/lib/server/middleware/request.ts index 9f4757f3beff..7f0c9e383ebd 100644 --- a/packages/net-stubbing/lib/server/middleware/request.ts +++ b/packages/net-stubbing/lib/server/middleware/request.ts @@ -18,6 +18,7 @@ import { getBodyEncoding, } from '../util' import { InterceptedRequest } from '../intercepted-request' +import { telemetry } from '@packages/telemetry' // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http @@ -25,6 +26,8 @@ import { InterceptedRequest } from '../intercepted-request' const debug = null export const SetMatchingRoutes: RequestMiddleware = async function () { + const span = telemetry.startSpan({ name: 'set:matching:routes', parentSpan: this.reqMiddlewareSpan, isVerbose: true }) + if (matchesRoutePreflight(this.netStubbingState.routes, this.req)) { // send positive CORS preflight response return sendStaticResponse(this, { @@ -41,6 +44,7 @@ export const SetMatchingRoutes: RequestMiddleware = async function () { this.req.matchingRoutes = [...getRoutesForRequest(this.netStubbingState.routes, this.req)] + span?.end() this.next() } @@ -48,8 +52,12 @@ export const SetMatchingRoutes: RequestMiddleware = async function () { * Called when a new request is received in the proxy layer. */ export const InterceptRequest: RequestMiddleware = async function () { + const span = telemetry.startSpan({ name: 'intercept:request', parentSpan: this.reqMiddlewareSpan, isVerbose: true }) + if (!this.req.matchingRoutes?.length) { // not intercepted, carry on normally... + span?.end() + return this.next() } @@ -166,8 +174,12 @@ export const InterceptRequest: RequestMiddleware = async function () { if (request.responseSent) { // request has been fulfilled with a response already, do not send the request outgoing // @see https://github.com/cypress-io/cypress/issues/15841 + span?.end() + return this.end() } + span?.end() + return request.continueRequest() } diff --git a/packages/net-stubbing/lib/server/middleware/response.ts b/packages/net-stubbing/lib/server/middleware/response.ts index 1cdd9f5686c0..caedfc60fd2d 100644 --- a/packages/net-stubbing/lib/server/middleware/response.ts +++ b/packages/net-stubbing/lib/server/middleware/response.ts @@ -3,7 +3,6 @@ import { concatStream, httpUtils } from '@packages/network' import Debug from 'debug' import type { Readable } from 'stream' import { getEncoding } from 'istextorbinary' - import type { ResponseMiddleware, } from '@packages/proxy' diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index f4c50493494e..7d3884f5391a 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -1,4 +1,16 @@ +import Bluebird from 'bluebird' +import chalk from 'chalk' +import Debug from 'debug' import _ from 'lodash' +import { errorUtils } from '@packages/errors' +import { DeferredSourceMapCache } from '@packages/rewriter' +import { telemetry, Span } from '@packages/telemetry' +import ErrorMiddleware from './error-middleware' +import RequestMiddleware from './request-middleware' +import ResponseMiddleware from './response-middleware' +import { HttpBuffers } from './util/buffers' +import { GetPreRequestCb, PreRequests } from './util/prerequests' + import type EventEmitter from 'events' import type CyServer from '@packages/server' import type { @@ -6,23 +18,13 @@ import type { CypressOutgoingResponse, BrowserPreRequest, } from '@packages/proxy' -import Debug from 'debug' -import chalk from 'chalk' -import ErrorMiddleware from './error-middleware' -import { HttpBuffers } from './util/buffers' -import { GetPreRequestCb, PreRequests } from './util/prerequests' import type { IncomingMessage } from 'http' import type { NetStubbingState } from '@packages/net-stubbing' -import Bluebird from 'bluebird' import type { Readable } from 'stream' import type { Request, Response } from 'express' -import RequestMiddleware from './request-middleware' -import ResponseMiddleware from './response-middleware' -import { DeferredSourceMapCache } from '@packages/rewriter' import type { RemoteStates } from '@packages/server/lib/remote_states' import type { CookieJar, SerializableAutomationCookie } from '@packages/server/lib/util/cookies' import type { RequestedWithAndCredentialManager } from '@packages/server/lib/util/requestedWithAndCredentialManager' -import { errorUtils } from '@packages/errors' function getRandomColorFn () { return chalk.hex(`#${Number( @@ -30,6 +32,10 @@ function getRandomColorFn () { ).toString(16).padStart(6, 'F').toUpperCase()}`) } +export const isVerboseTelemetry = true + +const isVerbose = isVerboseTelemetry + export const debugVerbose = Debug('cypress-verbose:proxy:http') export enum HttpStages { @@ -49,6 +55,9 @@ export type HttpMiddlewareStacks = { type HttpMiddlewareCtx = { req: CypressIncomingRequest res: CypressOutgoingResponse + handleHttpRequestSpan?: Span + reqMiddlewareSpan?: Span + resMiddlewareSpan?: Span shouldCorrelatePreRequests: () => boolean stage: HttpStages debug: Debug.Debugger @@ -164,6 +173,7 @@ export function _runStage (type: HttpStages, ctx: any, onError: Function) { function _end (retval?) { ctx.res.off('close', onClose) + if (ended) { return } @@ -215,6 +225,7 @@ export function _runStage (type: HttpStages, ctx: any, onError: Function) { middleware.call(fullCtx) } catch (err) { err.message = `Internal error while proxying "${ctx.req.method} ${ctx.req.proxiedUrl}" in ${middlewareName}:\n${err.message}` + errorUtils.logError(err) fullCtx.onError(err) } @@ -255,7 +266,6 @@ export class Http { constructor (opts: ServerCtx & { middleware?: HttpMiddlewareStacks }) { this.buffers = new HttpBuffers() this.deferredSourceMapCache = new DeferredSourceMapCache(opts.request) - this.config = opts.config this.shouldCorrelatePreRequests = opts.shouldCorrelatePreRequests || (() => false) this.getFileServerToken = opts.getFileServerToken @@ -273,7 +283,7 @@ export class Http { } } - handle (req: CypressIncomingRequest, res: CypressOutgoingResponse) { + handleHttpRequest (req: CypressIncomingRequest, res: CypressOutgoingResponse, handleHttpRequestSpan?: Span) { const colorFn = debugVerbose.enabled ? getRandomColorFn() : undefined const debugUrl = debugVerbose.enabled ? (req.proxiedUrl.length > 80 ? `${req.proxiedUrl.slice(0, 80)}...` : req.proxiedUrl) @@ -282,6 +292,7 @@ export class Http { const ctx: HttpMiddlewareCtx = { req, res, + handleHttpRequestSpan, buffers: this.buffers, config: this.config, shouldCorrelatePreRequests: this.shouldCorrelatePreRequests, @@ -331,6 +342,14 @@ export class Http { return _runStage(HttpStages.Error, ctx, onError) } + // start the span that is responsible for recording the start time of the entire middleware run on the stack + // make this span a part of the middleware ctx so we can keep names simple when correlating + ctx.reqMiddlewareSpan = telemetry.startSpan({ + name: 'request:middleware', + parentSpan: handleHttpRequestSpan, + isVerbose, + }) + return _runStage(HttpStages.IncomingRequest, ctx, onError) .then(() => { // If the response has been destroyed after handling the incoming request, it implies the that request was canceled by the browser. @@ -340,7 +359,17 @@ export class Http { } if (ctx.incomingRes) { + // start the span that is responsible for recording the start time of the entire middleware run on the stack + ctx.resMiddlewareSpan = telemetry.startSpan({ + name: 'response:middleware', + parentSpan: handleHttpRequestSpan, + isVerbose, + }) + return _runStage(HttpStages.IncomingResponse, ctx, onError) + .finally(() => { + ctx.resMiddlewareSpan?.end() + }) } return ctx.debug('Warning: Request was not fulfilled with a response.') diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index 573b1aad4124..dbdb797b231e 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -1,11 +1,15 @@ import _ from 'lodash' import { blocked, cors } from '@packages/network' import { InterceptRequest, SetMatchingRoutes } from '@packages/net-stubbing' -import type { HttpMiddleware } from './' -import { getSameSiteContext, addCookieJarCookiesToRequest, shouldAttachAndSetCookies } from './util/cookies' +import { telemetry } from '@packages/telemetry' +import { isVerboseTelemetry as isVerbose } from '.' +import { + addCookieJarCookiesToRequest, getSameSiteContext, shouldAttachAndSetCookies, +} from './util/cookies' import { doesTopNeedToBeSimulated } from './util/top-simulation' -import type { CypressIncomingRequest } from '../types' +import type { HttpMiddleware } from './' +import type { CypressIncomingRequest } from '../types' // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -24,9 +28,15 @@ const LogRequest: RequestMiddleware = function () { } const ExtractCypressMetadataHeaders: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'extract:cypress:metadata:headers', parentSpan: this.reqMiddlewareSpan, isVerbose }) + this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame'] const requestIsXhrOrFetch = this.req.headers['x-cypress-is-xhr-or-fetch'] + span?.setAttributes({ + isAUTFrame: this.req.isAUTFrame, + }) + if (this.req.headers['x-cypress-is-aut-frame']) { delete this.req.headers['x-cypress-is-aut-frame'] } @@ -53,11 +63,25 @@ const ExtractCypressMetadataHeaders: RequestMiddleware = function () { this.req.requestedWith = requestedWith this.req.credentialsLevel = credentialStatus + + span?.setAttributes({ + calculatedResourceType: this.req.resourceType, + credentialsLevel: credentialStatus, + }) + + span?.end() this.next() } const MaybeSimulateSecHeaders: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:simulate:sec:headers', parentSpan: this.reqMiddlewareSpan, isVerbose }) + + span?.setAttributes({ + experimentalModifyObstructiveThirdPartyCode: this.config.experimentalModifyObstructiveThirdPartyCode, + }) + if (!this.config.experimentalModifyObstructiveThirdPartyCode) { + span?.end() this.next() return @@ -65,14 +89,33 @@ const MaybeSimulateSecHeaders: RequestMiddleware = function () { // Do NOT disclose destination to an iframe and simulate if iframe was top if (this.req.isAUTFrame && this.req.headers['sec-fetch-dest'] === 'iframe') { - this.req.headers['sec-fetch-dest'] = 'document' + const secFetchDestModifiedTo = 'document' + + span?.setAttributes({ + secFetchDestModifiedFrom: this.req.headers['sec-fetch-dest'], + secFetchDestModifiedTo, + }) + + this.req.headers['sec-fetch-dest'] = secFetchDestModifiedTo } + span?.end() this.next() } const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { - if (!doesTopNeedToBeSimulated(this)) { + const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: this.reqMiddlewareSpan, isVerbose }) + + const doesTopNeedSimulation = doesTopNeedToBeSimulated(this) + + span?.setAttributes({ + doesTopNeedToBeSimulated: doesTopNeedSimulation, + resourceType: this.req.resourceType, + }) + + if (!doesTopNeedSimulation) { + span?.end() + return this.next() } @@ -80,8 +123,15 @@ const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { const currentAUTUrl = this.getAUTUrl() const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.requestedWith, this.req.credentialsLevel, this.req.isAUTFrame) + span?.setAttributes({ + currentAUTUrl, + shouldCookiesBeAttachedToRequest, + }) + this.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`) if (!shouldCookiesBeAttachedToRequest) { + span?.end() + return this.next() } @@ -91,28 +141,61 @@ const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { this.req.isAUTFrame, ) + span?.setAttributes({ + sameSiteContext, + currentAUTUrl, + isAUTFrame: this.req.isAUTFrame, + }) + const applicableCookiesInCookieJar = this.getCookieJar().getCookies(this.req.proxiedUrl, sameSiteContext) const cookiesOnRequest = (this.req.headers['cookie'] || '').split('; ') - this.debug('existing cookies on request from cookie jar: %s', applicableCookiesInCookieJar.join('; ')) - this.debug('add cookies to request from header: %s', cookiesOnRequest.join('; ')) + const existingCookiesInJar = applicableCookiesInCookieJar.join('; ') + const addedCookiesFromHeader = cookiesOnRequest.join('; ') + + this.debug('existing cookies on request from cookie jar: %s', existingCookiesInJar) + this.debug('add cookies to request from header: %s', addedCookiesFromHeader) // if the cookie header is empty (i.e. ''), set it to undefined for expected behavior this.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined + span?.setAttributes({ + existingCookiesInJar, + addedCookiesFromHeader, + cookieHeader: this.req.headers['cookie'], + }) + this.debug('cookies being sent with request: %s', this.req.headers['cookie']) + + span?.end() this.next() } const CorrelateBrowserPreRequest: RequestMiddleware = async function () { + const span = telemetry.startSpan({ name: 'correlate:prerequest', parentSpan: this.reqMiddlewareSpan, isVerbose }) + + const shouldCorrelatePreRequests = this.shouldCorrelatePreRequests() + + span?.setAttributes({ + shouldCorrelatePreRequest: shouldCorrelatePreRequests, + }) + if (!this.shouldCorrelatePreRequests()) { + span?.end() + return this.next() } const copyResourceTypeAndNext = () => { this.req.resourceType = this.req.browserPreRequest?.resourceType - this.next() + span?.setAttributes({ + resourceType: this.req.resourceType, + }) + + span?.end() + + return this.next() } if (this.req.headers['x-cypress-resolving-url']) { @@ -168,52 +251,103 @@ function shouldLog (req: CypressIncomingRequest) { } const SendToDriver: RequestMiddleware = function () { - if (shouldLog(this.req) && this.req.browserPreRequest) { + const span = telemetry.startSpan({ name: 'send:to:driver', parentSpan: this.reqMiddlewareSpan, isVerbose }) + + const shouldLogReq = shouldLog(this.req) + + if (shouldLogReq && this.req.browserPreRequest) { this.socket.toDriver('request:event', 'incoming:request', this.req.browserPreRequest) } + span?.setAttributes({ + shouldLogReq, + hasBrowserPreRequest: !!this.req.browserPreRequest, + }) + + span?.end() this.next() } const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:end:with:buffered:response', parentSpan: this.reqMiddlewareSpan, isVerbose }) + const buffer = this.buffers.take(this.req.proxiedUrl) + span?.setAttributes({ + hasBuffer: !!buffer, + }) + if (buffer) { this.debug('ending request with buffered response') // NOTE: Only inject fullCrossOrigin here if the super domain origins do not match in order to keep parity with cypress application reloads this.res.wantsInjection = buffer.urlDoesNotMatchPolicyBasedOnDomain ? 'fullCrossOrigin' : 'full' + span?.setAttributes({ + wantsInjection: this.res.wantsInjection, + }) + + span?.end() + this.reqMiddlewareSpan?.end() + return this.onResponse(buffer.response, buffer.stream) } + span?.end() this.next() } const RedirectToClientRouteIfUnloaded: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'redirect:to:client:route:if:unloaded', parentSpan: this.reqMiddlewareSpan, isVerbose }) + + const hasAppUnloaded = this.req.cookies['__cypress.unload'] + + span?.setAttributes({ + hasAppUnloaded, + }) + // if we have an unload header it means our parent app has been navigated away // directly and we need to automatically redirect to the clientRoute - if (this.req.cookies['__cypress.unload']) { + if (hasAppUnloaded) { + span?.setAttributes({ + redirectedTo: this.config.clientRoute, + }) + this.res.redirect(this.config.clientRoute) + span?.end() + return this.end() } + span?.end() this.next() } const EndRequestsToBlockedHosts: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'end:requests:to:block:hosts', parentSpan: this.reqMiddlewareSpan, isVerbose }) + const { blockHosts } = this.config + span?.setAttributes({ + areBlockHostsConfigured: !!blockHosts, + }) + if (blockHosts) { const matches = blocked.matches(this.req.proxiedUrl, blockHosts) + span?.setAttributes({ + didUrlMatchBlockedHosts: !!matches, + }) + if (matches) { this.res.set('x-cypress-matched-blocked-host', matches) this.debug('blocking request %o', { matches }) this.res.status(503).end() + span?.end() + return this.end() } } @@ -222,17 +356,30 @@ const EndRequestsToBlockedHosts: RequestMiddleware = function () { } const StripUnsupportedAcceptEncoding: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'strip:unsupported:accept:encoding', parentSpan: this.reqMiddlewareSpan, isVerbose }) + // Cypress can only support plaintext or gzip, so make sure we don't request anything else const acceptEncoding = this.req.headers['accept-encoding'] + span?.setAttributes({ + acceptEncodingHeaderPresent: !!acceptEncoding, + }) + if (acceptEncoding) { - if (acceptEncoding.includes('gzip')) { + const doesAcceptHeadingIncludeGzip = acceptEncoding.includes('gzip') + + span?.setAttributes({ + doesAcceptHeadingIncludeGzip, + }) + + if (doesAcceptHeadingIncludeGzip) { this.req.headers['accept-encoding'] = 'gzip' } else { delete this.req.headers['accept-encoding'] } } + span?.end() this.next() } @@ -242,32 +389,60 @@ function reqNeedsBasicAuthHeaders (req, { auth, origin }: Cypress.RemoteState) { } const MaybeSetBasicAuthHeaders: RequestMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:set:basic:auth:headers', parentSpan: this.reqMiddlewareSpan, isVerbose }) + // get the remote state for the proxied url const remoteState = this.remoteStates.get(this.req.proxiedUrl) - if (remoteState?.auth && reqNeedsBasicAuthHeaders(this.req, remoteState)) { + const doesReqNeedBasicAuthHeaders = remoteState?.auth && reqNeedsBasicAuthHeaders(this.req, remoteState) + + span?.setAttributes({ + doesReqNeedBasicAuthHeaders, + }) + + if (remoteState?.auth && doesReqNeedBasicAuthHeaders) { const { auth } = remoteState const base64 = Buffer.from(`${auth.username}:${auth.password}`).toString('base64') this.req.headers['authorization'] = `Basic ${base64}` } + span?.end() this.next() } const SendRequestOutgoing: RequestMiddleware = function () { + // end the request middleware span here before we make + // our outbound request so we can see that outside + // of the internal cypress middleware handlers + this.reqMiddlewareSpan?.end() + + // the actual req/resp time outbound from the proxy server + const span = telemetry.startSpan({ + name: 'outgoing:request:ttfb', + parentSpan: this.handleHttpRequestSpan, + isVerbose, + }) + const requestOptions = { + browserPreRequest: this.req.browserPreRequest, timeout: this.req.responseTimeout, strictSSL: false, followRedirect: this.req.followRedirect || false, retryIntervals: [], url: this.req.proxiedUrl, + time: !!span, // include timingPhases } const requestBodyBuffered = !!this.req.body const { strategy, origin, fileServer } = this.remoteStates.current() + span?.setAttributes({ + requestBodyBuffered, + strategy, + }) + if (strategy === 'file' && requestOptions.url.startsWith(origin)) { this.req.headers['x-cypress-authorization'] = this.getFileServerToken() @@ -283,12 +458,55 @@ const SendRequestOutgoing: RequestMiddleware = function () { const onSocketClose = () => { this.debug('request aborted') + // if the request is aborted, close out the middleware span and http span. the response middleware did not run + + this.reqMiddlewareSpan?.setAttributes({ + requestAborted: true, + }) + + this.reqMiddlewareSpan?.end() + this.handleHttpRequestSpan?.end() + req.abort() } req.on('error', this.onError) - req.on('response', (incomingRes) => this.onResponse(incomingRes, req)) + req.on('response', (incomingRes) => { + if (span) { + const { timings } = incomingRes.request + + if (!timings.socket) { + timings.socket = 0 + } + + if (!timings.lookup) { + timings.lookup = timings.socket + } + + if (!timings.connect) { + timings.connect = timings.lookup + } + + if (!timings.response) { + timings.response = timings.connect + } + + span.setAttributes({ + 'request.timing.socket': timings.socket, + 'request.timing.dns': timings.lookup - timings.socket, + 'request.timing.tcp': timings.connect - timings.lookup, + 'request.timing.firstByte': timings.response - timings.connect, + 'request.timing.totalUntilFirstByte': timings.response, + // download and total are not available yet + }) + + span.end() + } + + this.onResponse(incomingRes, req) + }) + // NOTE: this is an odd place to remove this listener this.req.res?.on('finish', () => { socket.removeListener('close', onSocketClose) }) diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 918764cbf59d..2eaf17e71ddc 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -1,22 +1,25 @@ -import _ from 'lodash' import charset from 'charset' -import type Debug from 'debug' -import type { CookieOptions } from 'express' -import { cors, concatStream, httpUtils } from '@packages/network' -import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy' -import type { HttpMiddleware, HttpMiddlewareThis } from '.' import iconv from 'iconv-lite' -import type { IncomingMessage, IncomingHttpHeaders } from 'http' -import { InterceptResponse } from '@packages/net-stubbing' +import _ from 'lodash' import { PassThrough, Readable } from 'stream' -import * as rewriter from './util/rewriter' -import zlib from 'zlib' import { URL } from 'url' +import zlib from 'zlib' +import { InterceptResponse } from '@packages/net-stubbing' +import { concatStream, cors, httpUtils } from '@packages/network' +import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' +import { telemetry } from '@packages/telemetry' +import { isVerboseTelemetry as isVerbose } from '.' import { CookiesHelper } from './util/cookies' +import * as rewriter from './util/rewriter' import { doesTopNeedToBeSimulated } from './util/top-simulation' -import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' -interface ResponseMiddlewareProps { +import type Debug from 'debug' +import type { CookieOptions } from 'express' +import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy' +import type { HttpMiddleware, HttpMiddlewareThis } from '.' +import type { IncomingMessage, IncomingHttpHeaders } from 'http' + +export interface ResponseMiddlewareProps { /** * Before using `res.incomingResStream`, `prepareResStream` can be used * to remove any encoding that prevents it from being returned as plain text. @@ -141,6 +144,7 @@ const stringifyFeaturePolicy = (policy: any): string => { const LogResponse: ResponseMiddleware = function () { this.debug('received response %o', { + browserPreRequest: _.pick(this.req.browserPreRequest, 'requestId'), req: _.pick(this.req, 'method', 'proxiedUrl', 'headers'), incomingRes: _.pick(this.incomingRes, 'headers', 'statusCode'), }) @@ -150,17 +154,28 @@ const LogResponse: ResponseMiddleware = function () { const AttachPlainTextStreamFn: ResponseMiddleware = function () { this.makeResStreamPlainText = function () { + const span = telemetry.startSpan({ name: 'make:res:stream:plain:text', parentSpan: this.resMiddlewareSpan, isVerbose }) + this.debug('ensuring resStream is plaintext') - if (!this.isGunzipped && resIsGzipped(this.incomingRes)) { + const isResGunzupped = resIsGzipped(this.incomingRes) + + span?.setAttributes({ + isResGunzupped, + }) + + if (!this.isGunzipped && isResGunzupped) { this.debug('gunzipping response body') const gunzip = zlib.createGunzip(zlibOptions) + // TODO: how do we measure the ctx pipe via telemetry? this.incomingResStream = this.incomingResStream.pipe(gunzip).on('error', this.onError) this.isGunzipped = true } + + span?.end() } this.next() @@ -239,6 +254,8 @@ const PatchExpressSetHeader: ResponseMiddleware = function () { } const SetInjectionLevel: ResponseMiddleware = function () { + const span = telemetry.startSpan({ name: 'set:injection:level', parentSpan: this.resMiddlewareSpan, isVerbose }) + this.res.isInitial = this.req.cookies['__cypress.initial'] === 'true' const isHTML = resContentTypeIs(this.incomingRes, 'text/html') @@ -253,6 +270,14 @@ const SetInjectionLevel: ResponseMiddleware = function () { this.debug('determine injection') const isReqMatchSuperDomainOrigin = reqMatchesPolicyBasedOnDomain(this.req, this.remoteStates.current(), this.config.experimentalSkipDomainInjection) + + span?.setAttributes({ + isInitialInjection: this.res.isInitial, + isHTML, + isRenderedHTML, + isReqMatchSuperDomainOrigin, + }) + const getInjectionLevel = () => { if (this.incomingRes.headers['x-cypress-file-server-error'] && !this.res.isInitial) { this.debug('- partial injection (x-cypress-file-server-error)') @@ -265,6 +290,11 @@ const SetInjectionLevel: ResponseMiddleware = function () { const isAUTFrame = this.req.isAUTFrame const isHTMLLike = isHTML || isRenderedHTML + span?.setAttributes({ + isAUTFrame, + urlDoesNotMatchPolicyBasedOnDomain, + }) + if (urlDoesNotMatchPolicyBasedOnDomain && isAUTFrame && isHTMLLike) { this.debug('- cross origin injection') @@ -295,6 +325,10 @@ const SetInjectionLevel: ResponseMiddleware = function () { } if (this.res.wantsInjection != null) { + span?.setAttributes({ + isInjectionAlreadySet: true, + }) + this.debug('- already has injection: %s', this.res.wantsInjection) } @@ -322,13 +356,21 @@ const SetInjectionLevel: ResponseMiddleware = function () { // only modify JavasScript if matching the current origin policy or if experimentalModifyObstructiveThirdPartyCode is enabled (above) (resContentTypeIsJavaScript(this.incomingRes) && isReqMatchSuperDomainOrigin)) + span?.setAttributes({ + wantsInjection: this.res.wantsInjection, + wantsSecurityRemoved: this.res.wantsSecurityRemoved, + }) + this.debug('injection levels: %o', _.pick(this.res, 'isInitial', 'wantsInjection', 'wantsSecurityRemoved')) + span?.end() this.next() } // https://github.com/cypress-io/cypress/issues/6480 const MaybeStripDocumentDomainFeaturePolicy: ResponseMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:strip:document:domain:feature:policy', parentSpan: this.resMiddlewareSpan, isVerbose }) + const { 'feature-policy': featurePolicy } = this.incomingRes.headers if (featurePolicy) { @@ -339,6 +381,10 @@ const MaybeStripDocumentDomainFeaturePolicy: ResponseMiddleware = function () { const policy = stringifyFeaturePolicy(directives) + span?.setAttributes({ + isFeaturePolicy: !!policy, + }) + if (policy) { this.res.set('feature-policy', policy) } else { @@ -347,6 +393,7 @@ const MaybeStripDocumentDomainFeaturePolicy: ResponseMiddleware = function () { } } + span?.end() this.next() } @@ -388,11 +435,21 @@ const setSimulatedCookies = (ctx: HttpMiddlewareThis) = } const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { + const span = telemetry.startSpan({ name: 'maybe:copy:cookies:from:incoming:res', parentSpan: this.resMiddlewareSpan, isVerbose }) + const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie'] - if (!cookies || !cookies.length) { + const areCookiesPresent = !cookies || !cookies.length + + span?.setAttributes({ + areCookiesPresent, + }) + + if (areCookiesPresent) { setSimulatedCookies(this) + span?.end() + return this.next() } @@ -417,6 +474,10 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { // path, etc. It also removes cookies from the cookie jar if they've expired. const doesTopNeedSimulating = doesTopNeedToBeSimulated(this) + span?.setAttributes({ + doesTopNeedSimulating, + }) + const appendCookie = (cookie: string) => { // always call 'Set-Cookie' in the browser as cross origin or same site requests // can effectively set cookies in the browser if given correct credential permissions @@ -434,6 +495,8 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { appendCookie(cookie) }) + span?.end() + return this.next() } @@ -461,8 +524,15 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { setSimulatedCookies(this) const addedCookies = await cookiesHelper.getAddedCookies() + const wereSimCookiesAdded = addedCookies.length + + span?.setAttributes({ + wereSimCookiesAdded, + }) + + if (!wereSimCookiesAdded) { + span?.end() - if (!addedCookies.length) { return this.next() } @@ -473,6 +543,7 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { // from the driver once the page has loaded but before we run any further // commands this.serverBus.once('cross:origin:cookies:received', () => { + span?.end() this.next() }) @@ -483,10 +554,20 @@ const REDIRECT_STATUS_CODES: any[] = [301, 302, 303, 307, 308] // TODO: this shouldn't really even be necessary? const MaybeSendRedirectToClient: ResponseMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:send:redirect:to:client', parentSpan: this.resMiddlewareSpan, isVerbose }) + const { statusCode, headers } = this.incomingRes const newUrl = headers['location'] - if (!REDIRECT_STATUS_CODES.includes(statusCode) || !newUrl) { + const isRedirectNeeded = !REDIRECT_STATUS_CODES.includes(statusCode) || !newUrl + + span?.setAttributes({ + isRedirectNeeded, + }) + + if (isRedirectNeeded) { + span?.end() + return this.next() } @@ -495,6 +576,9 @@ const MaybeSendRedirectToClient: ResponseMiddleware = function () { this.debug('redirecting to new url %o', { statusCode, newUrl }) this.res.redirect(Number(statusCode), newUrl) + span?.end() + + // TODO; how do we instrument end? return this.end() } @@ -525,7 +609,15 @@ const MaybeEndWithEmptyBody: ResponseMiddleware = function () { } const MaybeInjectHtml: ResponseMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:inject:html', parentSpan: this.resMiddlewareSpan, isVerbose }) + + span?.setAttributes({ + wantsInjection: this.res.wantsInjection, + }) + if (!this.res.wantsInjection) { + span?.end() + return this.next() } @@ -535,6 +627,8 @@ const MaybeInjectHtml: ResponseMiddleware = function () { this.makeResStreamPlainText() + const streamSpan = telemetry.startSpan({ name: `maybe:inject:html-resp:stream`, parentSpan: span, isVerbose }) + this.incomingResStream.pipe(concatStream(async (body) => { const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body, this.debug) @@ -562,12 +656,24 @@ const MaybeInjectHtml: ResponseMiddleware = function () { pt.end() this.incomingResStream = pt + + streamSpan?.end() this.next() - })).on('error', this.onError) + })).on('error', this.onError).once('finish', () => { + span?.end() + }) } const MaybeRemoveSecurity: ResponseMiddleware = function () { + const span = telemetry.startSpan({ name: 'maybe:remove:security', parentSpan: this.resMiddlewareSpan, isVerbose }) + + span?.setAttributes({ + wantsSecurityRemoved: this.res.wantsSecurityRemoved || false, + }) + if (!this.res.wantsSecurityRemoved) { + span?.end() + return this.next() } @@ -576,6 +682,9 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () { this.makeResStreamPlainText() this.incomingResStream.setEncoding('utf8') + + const streamSpan = telemetry.startSpan({ name: `maybe:remove:security-resp:stream`, parentSpan: span, isVerbose }) + this.incomingResStream = this.incomingResStream.pipe(rewriter.security({ isNotJavascript: !resContentTypeIsJavaScript(this.incomingRes), useAstSourceRewriting: this.config.experimentalSourceRewriting, @@ -583,15 +692,25 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () { modifyObstructiveCode: this.config.modifyObstructiveCode, url: this.req.proxiedUrl, deferSourceMapRewrite: this.deferSourceMapRewrite, - })).on('error', this.onError) + })).on('error', this.onError).once('finish', () => { + streamSpan?.end() + }) + span?.end() this.next() } const GzipBody: ResponseMiddleware = function () { if (this.isGunzipped) { this.debug('regzipping response body') - this.incomingResStream = this.incomingResStream.pipe(zlib.createGzip(zlibOptions)).on('error', this.onError) + const span = telemetry.startSpan({ name: 'gzip:body', parentSpan: this.resMiddlewareSpan, isVerbose }) + + this.incomingResStream = this.incomingResStream + .pipe(zlib.createGzip(zlibOptions)) + .on('error', this.onError) + .once('finish', () => { + span?.end() + }) } this.next() @@ -605,7 +724,10 @@ const SendResponseBodyToClient: ResponseMiddleware = function () { } this.incomingResStream.pipe(this.res).on('error', this.onError) - this.res.on('end', () => this.end()) + + this.res.once('finish', () => { + this.end() + }) } export default { diff --git a/packages/proxy/lib/network-proxy.ts b/packages/proxy/lib/network-proxy.ts index 01976365feaf..bd7f437d24cd 100644 --- a/packages/proxy/lib/network-proxy.ts +++ b/packages/proxy/lib/network-proxy.ts @@ -1,3 +1,4 @@ +import { telemetry } from '@packages/telemetry' import { Http, ServerCtx } from './http' import type { BrowserPreRequest } from './types' @@ -13,7 +14,20 @@ export class NetworkProxy { } handleHttpRequest (req, res) { - this.http.handle(req, res) + const span = telemetry.startSpan({ + name: 'network:proxy:handleHttpRequest', + opts: { + attributes: { + 'network:proxy:url': req.proxiedUrl, + 'network:proxy:contentType': req.get('content-type'), + }, + }, + isVerbose: true, + }) + + this.http.handleHttpRequest(req, res, span).finally(() => { + span?.end() + }) } handleSourceMapRequest (req, res) { diff --git a/packages/proxy/test/unit/http/index.spec.ts b/packages/proxy/test/unit/http/index.spec.ts index 2d394857e489..d528f7379420 100644 --- a/packages/proxy/test/unit/http/index.spec.ts +++ b/packages/proxy/test/unit/http/index.spec.ts @@ -1,6 +1,6 @@ -import { Http, HttpStages } from '../../../lib/http' import { expect } from 'chai' import sinon from 'sinon' +import { Http, HttpStages } from '../../../lib/http' describe('http', function () { context('Http.handle', function () { @@ -49,7 +49,7 @@ describe('http', function () { return new Http(httpOpts) // @ts-ignore - .handle({}, { on, off }) + .handleHttpRequest({}, { on, off }) .then(function () { expect(incomingRequest, 'incomingRequest').to.be.calledOnce expect(incomingResponse, 'incomingResponse').to.be.calledOnce @@ -69,7 +69,7 @@ describe('http', function () { return new Http(httpOpts) // @ts-ignore - .handle({ method: 'GET', proxiedUrl: 'url' }, { on, off }) + .handleHttpRequest({ method: 'GET', proxiedUrl: 'url' }, { on, off }) .then(function () { expect(incomingRequest).to.be.calledOnce expect(incomingResponse).to.not.be.called @@ -94,7 +94,7 @@ describe('http', function () { return new Http(httpOpts) // @ts-ignore - .handle({ method: 'GET', proxiedUrl: 'url' }, { on, off }) + .handleHttpRequest({ method: 'GET', proxiedUrl: 'url' }, { on, off }) .then(function () { expect(incomingRequest).to.be.calledOnce expect(incomingResponse).to.be.calledOnce @@ -159,7 +159,7 @@ describe('http', function () { return new Http(httpOpts) // @ts-ignore - .handle({ method: 'GET', proxiedUrl: 'url' }, { on, off }) + .handleHttpRequest({ method: 'GET', proxiedUrl: 'url' }, { on, off }) .then(function () { [ incomingRequest, incomingRequest2, diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index c18b96f22d5a..f63ef09295e9 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -789,7 +789,7 @@ describe('http/request-middleware', () => { inputArgs: opts, on: (event, callback) => { if (event === 'response') { - callback() + callback({ request: { timings: {} } }) } }, } diff --git a/packages/server/lib/plugins/child/ts_node.js b/packages/server/lib/plugins/child/ts_node.js index 00c24f272cd1..7fcaa431e677 100644 --- a/packages/server/lib/plugins/child/ts_node.js +++ b/packages/server/lib/plugins/child/ts_node.js @@ -57,6 +57,8 @@ const getTsNodeOptions = (tsPath, registeredFile) => { // We do not want to ignore too much or too little // So for now we are only ignoring the explicit file that has issues '/packages/telemetry/dist/span-exporters/ipc-span-exporter', + '/packages/telemetry/dist/span-exporters/console-trace-link-exporter', + '/packages/telemetry/dist/processors/on-start-span-processor', ], // resolves tsconfig.json starting from the plugins directory // instead of the cwd (the project root) diff --git a/packages/server/test/unit/plugins/child/ts_node_spec.js b/packages/server/test/unit/plugins/child/ts_node_spec.js index 17d1a8e049a2..d6945aa08fcd 100644 --- a/packages/server/test/unit/plugins/child/ts_node_spec.js +++ b/packages/server/test/unit/plugins/child/ts_node_spec.js @@ -28,6 +28,8 @@ describe('lib/plugins/child/ts_node', () => { ignore: [ '(?:^|/)node_modules/', '/packages/telemetry/dist/span-exporters/ipc-span-exporter', + '/packages/telemetry/dist/span-exporters/console-trace-link-exporter', + '/packages/telemetry/dist/processors/on-start-span-processor', ], }) }) @@ -47,6 +49,8 @@ describe('lib/plugins/child/ts_node', () => { ignore: [ '(?:^|/)node_modules/', '/packages/telemetry/dist/span-exporters/ipc-span-exporter', + '/packages/telemetry/dist/span-exporters/console-trace-link-exporter', + '/packages/telemetry/dist/processors/on-start-span-processor', ], }) }) diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index e18d5a6c5865..c5233488ef26 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -6,6 +6,8 @@ This package is a convenience wrapper built around [open telemetry](https://open Telemetry in Cypress is disabled by default. To enable telemetry in Cypress set `CYPRESS_INTERNAL_ENABLE_TELEMETRY="true"`. +Verbose telemetry in Cypress is disabled by default when telemetry is enabled. To enable verbose telemetry in Cypress set `CYPRESS_INTERNAL_ENABLE_TELEMETRY_VERBOSE="true"`. This will enable telemetry for areas of the code that report a lot of events, such as the `@packages/proxy` code. + Telemetry data is sent to the cloud `/telemetry` endpoint. For the **Cypress cloud project only** we forward the telemetry data to [honeycomb](https://ui.honeycomb.io/cypress). For all other projects telemetry data is not stored. @@ -288,4 +290,4 @@ The metrics api is tbd. ## Open Telemetry Links * [otel docs](https://opentelemetry.io/docs/) -* [otel sdk](https://open-telemetry.github.io/opentelemetry-js/index.html) +* [otel sdk](https://open-telemetry.github.io/opentelemetry-js/index.html) \ No newline at end of file diff --git a/packages/telemetry/index.js b/packages/telemetry/index.js new file mode 100644 index 000000000000..3e040a203c82 --- /dev/null +++ b/packages/telemetry/index.js @@ -0,0 +1,5 @@ +if (process.env.CYPRESS_INTERNAL_ENV !== 'production') { + require('@packages/ts/register') +} + +module.exports = require('./src/node') diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index f49a54606fee..0fdc6ad47770 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -9,6 +9,7 @@ "build": "tsc", "build-prod": "yarn build", "check-ts": "tsc --noEmit && yarn -s tslint", + "clean": "rimraf dist", "test": "yarn test-unit", "test-unit": "mocha --config ./test/.mocharc.js", "tslint": "tslint --config ../ts/tslint.json --project .", diff --git a/packages/telemetry/src/browser.ts b/packages/telemetry/src/browser.ts index 6bd164ba0046..2391c5d5da10 100644 --- a/packages/telemetry/src/browser.ts +++ b/packages/telemetry/src/browser.ts @@ -8,7 +8,7 @@ import { OTLPTraceExporter } from './span-exporters/websocket-span-exporter' declare global { interface Window { - __CYPRESS_TELEMETRY__?: {context: {traceparent: string}, resources: Attributes} + __CYPRESS_TELEMETRY__?: {context: {context: {traceparent: string}, attributes: Attributes}, resources: Attributes, isVerbose: boolean} cypressTelemetrySingleton?: TelemetryClass | TelemetryNoop } } @@ -32,7 +32,7 @@ const init = ({ namespace, config }: { namespace: string, config: {version: stri throw ('Telemetry instance has already be initialized') } - const { context, resources } = window.__CYPRESS_TELEMETRY__ + const { context, resources, isVerbose } = window.__CYPRESS_TELEMETRY__ // We always use the websocket exporter for browser telemetry const exporter = new OTLPTraceExporter() @@ -52,6 +52,7 @@ const init = ({ namespace, config }: { namespace: string, config: {version: stri // See https://github.com/open-telemetry/opentelemetry-js/issues/2613 SpanProcessor: SimpleSpanProcessor, resources, + isVerbose, }) window.cypressTelemetrySingleton = telemetryInstance @@ -83,3 +84,5 @@ export const telemetry = { attachWebSocket: (ws: any) => (telemetryInstance.getExporter() as OTLPTraceExporter)?.attachWebSocket(ws), setRootContext: (context?: contextObject) => (telemetryInstance.setRootContext(context)), } + +export type { Span } from '@opentelemetry/api' diff --git a/packages/telemetry/src/detectors/githubActionsDetectorSync.ts b/packages/telemetry/src/detectors/githubActionsDetectorSync.ts new file mode 100644 index 000000000000..0edfd6384b24 --- /dev/null +++ b/packages/telemetry/src/detectors/githubActionsDetectorSync.ts @@ -0,0 +1,31 @@ +import type { DetectorSync, ResourceAttributes, IResource } from '@opentelemetry/resources' +import { Resource } from '@opentelemetry/resources' + +/** + * GithubActionsDetectorSync can be used to detect the presence of and create a Resource + * from github actions env variables. + */ +class GithubActionsDetectorSync implements DetectorSync { + /** + * Returns a {@link Resource} populated with attributes from the + * circle ci environment variable. + * + * @param config The resource detection config -- ignored + */ + detect (): IResource { + const attributes: ResourceAttributes = {} + + const { GITHUB_ACTION, GH_BRANCH, GITHUB_REF, GITHUB_SHA, GITHUB_RUN_NUMBER } = process.env + + if (GITHUB_ACTION) { + attributes['ci.github_action'] = GITHUB_ACTION + attributes['ci.build-number'] = GITHUB_RUN_NUMBER + attributes['ci.branch'] = GH_BRANCH || GITHUB_REF + attributes['SHA1'] = GITHUB_SHA + } + + return new Resource(attributes) + } +} + +export const githubActionsDetectorSync = new GithubActionsDetectorSync() diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 92d213beb445..8d0c0b4bd2d1 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,21 +1,31 @@ +import openTelemetry from '@opentelemetry/api' +import { detectResourcesSync, Resource } from '@opentelemetry/resources' +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' +import { OnStartSpanProcessor } from './processors/on-start-span-processor' +import { ConsoleTraceLinkExporter } from './span-exporters/console-trace-link-exporter' + import type { Span, SpanOptions, Tracer, Context, Attributes } from '@opentelemetry/api' import type { BasicTracerProvider, SimpleSpanProcessor, BatchSpanProcessor, SpanExporter } from '@opentelemetry/sdk-trace-base' import type { DetectorSync } from '@opentelemetry/resources' +const types = ['child', 'root'] as const -import openTelemetry/*, { diag, DiagConsoleLogger, DiagLogLevel }*/ from '@opentelemetry/api' -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' -import { Resource, detectResourcesSync } from '@opentelemetry/resources' +export const enabledValues = ['true', '1'] -const types = ['child', 'root'] as const +const environment = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'development') + +const SERVICE_NAME = 'cypress-app' type AttachType = typeof types[number]; -export type contextObject = { traceparent?: string } +export type contextObject = {context?: { traceparent?: string }, attributes?: Attributes} export type startSpanOptions = { name: string attachType?: AttachType active?: boolean + parentSpan?: Span + isVerbose?: boolean + key?: string opts?: SpanOptions } @@ -41,8 +51,10 @@ export class Telemetry implements TelemetryApi { spans: {[key: string]: Span} activeSpanQueue: Span[] rootContext?: Context + rootAttributes?: Attributes provider: BasicTracerProvider exporter: SpanExporter + isVerbose: boolean constructor ({ namespace, @@ -53,8 +65,9 @@ export class Telemetry implements TelemetryApi { SpanProcessor, exporter, resources = {}, + isVerbose = false, }: { - namespace?: string + namespace: string Provider: typeof BasicTracerProvider detectors: DetectorSync[] rootContextObject?: contextObject @@ -62,25 +75,48 @@ export class Telemetry implements TelemetryApi { SpanProcessor: typeof SimpleSpanProcessor | typeof BatchSpanProcessor exporter: SpanExporter resources?: Attributes + isVerbose: boolean }) { // For troubleshooting, set the log level to DiagLogLevel.DEBUG + // import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' // diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ALL) + this.isVerbose = isVerbose + // Setup default resources const resource = Resource.default().merge( new Resource({ ...resources, - [ SemanticResourceAttributes.SERVICE_NAME ]: 'cypress-app', + [ SemanticResourceAttributes.SERVICE_NAME ]: SERVICE_NAME, [ SemanticResourceAttributes.SERVICE_NAMESPACE ]: namespace, [ SemanticResourceAttributes.SERVICE_VERSION ]: version, }), ) // Merge resources and create a new provider of the desired type. - this.provider = new Provider({ resource: resource.merge(detectResourcesSync({ detectors })) }) + this.provider = new Provider({ + resource: resource.merge(detectResourcesSync({ detectors })), + }) - // Setup the console exporter - this.provider.addSpanProcessor(new SpanProcessor(exporter)) + // Setup the exporter + if (SpanProcessor.name === 'BatchSpanProcessor') { + this.provider.addSpanProcessor(new SpanProcessor(exporter, { + // Double the max queue size, We were seeing telemetry bursts that would result in loosing the top span. + maxQueueSize: 4056, + })) + } else { + this.provider.addSpanProcessor(new SpanProcessor(exporter)) + } + + // if local visualizations enabled, create composite exporter configured + // to send to both local exporter and main exporter + const honeyCombConsoleLinkExporter = new ConsoleTraceLinkExporter({ + serviceName: SERVICE_NAME, + team: 'cypress', + environment: (environment === 'production' ? 'cypress-app' : 'cypress-app-staging'), + }) + + this.provider.addSpanProcessor(new OnStartSpanProcessor(honeyCombConsoleLinkExporter)) // Initialize the provider this.provider.register() @@ -88,12 +124,8 @@ export class Telemetry implements TelemetryApi { // Save off the tracer this.tracer = openTelemetry.trace.getTracer('cypress', version) - this.setRootContext(rootContextObject) - // store off the root context to apply to new spans - if (rootContextObject && rootContextObject.traceparent) { - this.rootContext = openTelemetry.propagation.extract(openTelemetry.context.active(), rootContextObject) - } + this.setRootContext(rootContextObject) this.spans = {} this.activeSpanQueue = [] @@ -103,6 +135,7 @@ export class Telemetry implements TelemetryApi { /** * Starts a span with the given name. Stores off the span with the name as a key for later retrieval. * @param name - the span name + * @param key - they key associated with the span, to be used to retrieve the span, if not specified, the name is used. * @param attachType - Should this span be attached as a new root span or a child of the previous root span. * @param name - Set true if this span should have child spans of it's own. * @param opts - pass through for otel span opts @@ -112,39 +145,81 @@ export class Telemetry implements TelemetryApi { name, attachType = 'child', active = false, + parentSpan, opts = {}, + key, + isVerbose = false, }: startSpanOptions) { // Currently the latest span replaces any previous open or closed span and you can no longer access the replaced span. // This works well enough for now but may cause issue in the future. + // if the span is declared in verbose mode, but verbosity is disabled, no-op the span creation + if (isVerbose && !this.isVerbose) { + return undefined + } + let span: Span - // If root or implied root - if (attachType === 'root' || this.activeSpanQueue.length < 1) { + let parent: Span | undefined + + if (attachType === 'root' || (this.activeSpanQueue.length < 1 && !parentSpan)) { if (this.rootContext) { // Start span with external context span = this.tracer.startSpan(name, opts, this.rootContext) + + // This can only apply attributes set on the external root set up until the point at which it was sent. + if (this.rootAttributes) { + span.setAttributes(this.rootAttributes) + } } else { // Start span with no context span = this.tracer.startSpan(name, opts) } } else { // attach type must be child + // Prefer passed in parent + parent = parentSpan || this.activeSpanQueue[this.activeSpanQueue.length - 1] + // Create a context from the active span. - const ctx = openTelemetry.trace.setSpan(openTelemetry.context.active(), this.activeSpanQueue[this.activeSpanQueue.length - 1]!) + const ctx = openTelemetry.trace.setSpan(openTelemetry.context.active(), parent!) // Start span with parent context. span = this.tracer.startSpan(name, opts, ctx) } - // Save off span, duplicate names currently not handled. - this.spans[name] = span + //span keys must be unique, names do not. + if (environment === 'development' && key && key in this.spans) { + throw new Error(`Span key ${key} rejected. Span key already exists in spans map.`) + } - // If this is an active span, set it as the new active span - if (active) { - const _end = span.end + // Save off span + const spanKey = key || name + + this.spans[spanKey] = span + + // Setup function on span to recursively get parent attributes. + // Not bothering with types here since we only need this function within this function. + // @ts-expect-error + span.getAllAttributes = () => { + // @ts-expect-error + const parentAttributes = parent && parent.getAllAttributes ? parent.getAllAttributes() : {} + + const allAttributes = { + // @ts-expect-error + ...span.attributes, + ...parentAttributes, + } - // override the end function to allow us to pop the span off the queue if found. - span.end = (endTime) => { + // never propagate name + delete allAttributes['name'] + + return allAttributes + } + + // override the end function to allow us to pop the span off the queue if found. + const _end = span.end + + span.end = (endTime) => { + if (active) { // find the span in the queue by spanId const index = this.activeSpanQueue.findIndex((element: Span) => { return element.spanContext().spanId === span.spanContext().spanId @@ -154,10 +229,20 @@ export class Telemetry implements TelemetryApi { if (index > -1) { this.activeSpanQueue.splice(index, 1) } + } - _end.call(span, endTime) + // On span end recursively grab parent attributes + // @ts-ignore + if (parent && parent.getAllAttributes) { + // @ts-ignore + span.setAttributes(parent.getAllAttributes()) } + _end.call(span, endTime) + } + + // If this is an active span, set it as the new active span + if (active) { this.activeSpanQueue.push(span) } @@ -218,7 +303,8 @@ export class Telemetry implements TelemetryApi { openTelemetry.propagation.inject(ctx, myCtx) - return myCtx + // @ts-expect-error + return { context: myCtx, attributes: rootSpan.getAllAttributes() } } /** @@ -251,8 +337,9 @@ export class Telemetry implements TelemetryApi { */ setRootContext (rootContextObject?: contextObject): void { // store off the root context to apply to new spans - if (rootContextObject && rootContextObject.traceparent) { - this.rootContext = openTelemetry.propagation.extract(openTelemetry.context.active(), rootContextObject) + if (rootContextObject && rootContextObject.context && rootContextObject.context.traceparent) { + this.rootContext = openTelemetry.propagation.extract(openTelemetry.context.active(), rootContextObject.context) + this.rootAttributes = rootContextObject.attributes } } } diff --git a/packages/telemetry/src/node.ts b/packages/telemetry/src/node.ts index c3fe5bb84faa..d8132e05e5e2 100644 --- a/packages/telemetry/src/node.ts +++ b/packages/telemetry/src/node.ts @@ -1,23 +1,36 @@ import type { Span } from '@opentelemetry/api' import type { startSpanOptions, findActiveSpanOptions, contextObject } from './index' -import { Telemetry as TelemetryClass, TelemetryNoop } from './index' +import { + envDetectorSync, hostDetectorSync, osDetectorSync, processDetectorSync, +} from '@opentelemetry/resources' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' -import { envDetectorSync, processDetectorSync, osDetectorSync, hostDetectorSync } from '@opentelemetry/resources' import { circleCiDetectorSync } from './detectors/circleCiDetectorSync' -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' - -import { OTLPTraceExporter as OTLPTraceExporterIpc } from './span-exporters/ipc-span-exporter' +import { enabledValues, Telemetry as TelemetryClass, TelemetryNoop } from './index' import { OTLPTraceExporter as OTLPTraceExporterCloud } from './span-exporters/cloud-span-exporter' +import { OTLPTraceExporter as OTLPTraceExporterIpc } from './span-exporters/ipc-span-exporter' -export { OTLPTraceExporterIpc, OTLPTraceExporterCloud } +export { OTLPTraceExporterIpc, OTLPTraceExporterCloud, Span } let telemetryInstance: TelemetryNoop | TelemetryClass = new TelemetryNoop +/** + * Check if the env is enabled + * @returns boolean + */ +const isEnabledEnV = (): boolean => enabledValues.includes(process.env.CYPRESS_INTERNAL_ENABLE_TELEMETRY || '') + +/** + * Provide a single place to check if telemetry should be enabled in verbose mode. + * @returns boolean + */ +const isVerboseEnabled = (): boolean => enabledValues.includes(process.env.CYPRESS_INTERNAL_ENABLE_TELEMETRY_VERBOSE || '') + /** * Provide a single place to check if telemetry should be enabled. * @returns boolean */ -const isEnabled = (): boolean => process.env.CYPRESS_INTERNAL_ENABLE_TELEMETRY === 'true' +const isEnabled = (): boolean => isEnabledEnV() || isVerboseEnabled() /** * Initialize the telemetry singleton @@ -57,6 +70,7 @@ const init = ({ version, exporter, SpanProcessor: BatchSpanProcessor, + isVerbose: isVerboseEnabled(), }) return @@ -72,6 +86,7 @@ export const telemetry = { getActiveContextObject: () => telemetryInstance.getActiveContextObject(), getResources: () => telemetryInstance.getResources(), shutdown: () => telemetryInstance.shutdown(), + isVerbose: () => isVerboseEnabled(), exporter: (): void | OTLPTraceExporterIpc | OTLPTraceExporterCloud => telemetryInstance.getExporter() as void | OTLPTraceExporterIpc | OTLPTraceExporterCloud, } diff --git a/packages/telemetry/src/processors/on-start-span-processor.ts b/packages/telemetry/src/processors/on-start-span-processor.ts new file mode 100644 index 000000000000..d8ef97b6ec06 --- /dev/null +++ b/packages/telemetry/src/processors/on-start-span-processor.ts @@ -0,0 +1,15 @@ +import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import type { Span } from '@opentelemetry/sdk-trace-base' +import type { Context } from '@opentelemetry/api' + +/** + * An implementation of the {@link SpanProcessor} that converts the {@link Span} + * to {@link ReadableSpan} and passes it to the configured exporter. + * + * Only spans that are sampled are converted. + */ +export class OnStartSpanProcessor extends SimpleSpanProcessor { + onStart (span: Span, _parentContext: Context): void { + return this.onEnd(span) + } +} diff --git a/packages/telemetry/src/span-exporters/console-trace-link-exporter.ts b/packages/telemetry/src/span-exporters/console-trace-link-exporter.ts new file mode 100644 index 000000000000..0bfd714d703e --- /dev/null +++ b/packages/telemetry/src/span-exporters/console-trace-link-exporter.ts @@ -0,0 +1,77 @@ +import { ExportResult, ExportResultCode } from '@opentelemetry/core' + +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base' + +/** + * Builds and returns a {@link SpanExporter} that logs Honeycomb URLs for completed traces + * + * @remark This is not for production use. + * @param options The {@link HoneycombOptions} used to configure the exporter + * @returns the configured {@link ConsoleTraceLinkExporter} instance + */ + +export class ConsoleTraceLinkExporter implements SpanExporter { + private _traceUrl = '' + private _uniqueTraces: {[id: string]: string} = {} + // eslint-disable-next-line no-console + private _log = console.log + + constructor ({ + serviceName, + team, + environment, + }: { + serviceName: string + team: string + environment: string + }) { + this._traceUrl = buildTraceUrl(serviceName, team, environment) + } + + export ( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void, + ): void { + if (this._traceUrl) { + spans.forEach((span) => { + const { traceId, spanId } = span.spanContext() + + if (!span.ended) { + if (!Object.keys(this._uniqueTraces).includes(traceId)) { + this._uniqueTraces[traceId] = spanId + + this._log( + `Trace start: [${span.name}] - ${this._traceUrl}=${span.spanContext().traceId}`, + ) + } + } else if (this._uniqueTraces[traceId] === spanId) { + this._log( + `Trace end: [${span.name}] - ${this._traceUrl}=${span.spanContext().traceId}`, + ) + } + }) + } + + resultCallback({ code: ExportResultCode.SUCCESS }) + } + + shutdown (): Promise { + return Promise.resolve() + } +} + +/** + * Builds and returns a URL that is used to log when a trace is completed in the {@link ConsoleTraceLinkExporter}. + * + * @param serviceName the Honeycomb service name (or classic dataset) where data is stored + * @param team the Honeycomb team + * @param environment the Honeycomb environment + * @returns + */ +function buildTraceUrl ( + serviceName: string, + team: string, + environment: string, +): string { + return `https://ui.honeycomb.io/${team}/environments/${environment}/datasets/${serviceName}/trace?trace_id` +} diff --git a/packages/telemetry/test/browser.spec.ts b/packages/telemetry/test/browser.spec.ts index 1d53d398f916..b3ecdab34fef 100644 --- a/packages/telemetry/test/browser.spec.ts +++ b/packages/telemetry/test/browser.spec.ts @@ -53,7 +53,7 @@ describe('telemetry is disabled', () => { describe('getActiveContextObject', () => { it('returns an empty object', () => { - expect(telemetry.getActiveContextObject().traceparent).to.be.undefined + expect(telemetry.getActiveContextObject().context).to.be.undefined }) }) @@ -81,11 +81,14 @@ describe('telemetry is enabled', () => { // @ts-expect-error global.window.__CYPRESS_TELEMETRY__ = { context: { - traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01', + context: { + traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01', + }, }, resources: { herp: 'derp', }, + isVerbose: false, } expect(telemetry.init({ @@ -133,7 +136,7 @@ describe('telemetry is enabled', () => { expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw - expect(telemetry.getActiveContextObject().traceparent).to.be.undefined + expect(telemetry.getActiveContextObject().context).to.be.undefined }) }) @@ -141,7 +144,7 @@ describe('telemetry is enabled', () => { it('returns an empty object', () => { const spanny = telemetry.startSpan({ name: 'active', active: true }) - expect(telemetry.getActiveContextObject().traceparent).to.exist + expect(telemetry.getActiveContextObject().context.traceparent).to.exist spanny?.end() }) }) @@ -167,12 +170,10 @@ describe('telemetry is enabled', () => { describe('setRootContext', () => { it('it sets the context', () => { - console.log('bef', telemetry.getActiveContextObject()) - // @ts-expect-error expect(window.cypressTelemetrySingleton?.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0') - telemetry.setRootContext({ traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' }) + telemetry.setRootContext({ context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' } }) // @ts-expect-error expect(window.cypressTelemetrySingleton?.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b1') diff --git a/packages/telemetry/test/detectors/githubActionsDetectorSync.spec.ts b/packages/telemetry/test/detectors/githubActionsDetectorSync.spec.ts new file mode 100644 index 000000000000..54bdac69787a --- /dev/null +++ b/packages/telemetry/test/detectors/githubActionsDetectorSync.spec.ts @@ -0,0 +1,97 @@ +import { expect } from 'chai' + +import { githubActionsDetectorSync } from '../../src/detectors/githubActionsDetectorSync' + +describe('githubActionsDetectorSync', () => { + describe('undefined values', () => { + const processValues: any = {} + + beforeEach(() => { + // cache values + processValues.GITHUB_ACTION = process.env.GITHUB_ACTION + processValues.GH_BRANCH = process.env.GH_BRANCH + processValues.GITHUB_REF = process.env.GITHUB_REF + processValues.GITHUB_RUN_NUMBER = process.env.GITHUB_RUN_NUMBER + processValues.GITHUB_SHA = process.env.GITHUB_SHA + + //reset values + delete process.env.GITHUB_ACTION + delete process.env.GH_BRANCH + delete process.env.GITHUB_REF + delete process.env.GITHUB_RUN_NUMBER + delete process.env.GITHUB_SHA + }) + + afterEach(() => { + // Replace values + process.env.GITHUB_ACTION = processValues.GITHUB_ACTION + process.env.GH_BRANCH = processValues.GH_BRANCH + process.env.GITHUB_REF = processValues.GITHUB_REF + process.env.GITHUB_RUN_NUMBER = processValues.GITHUB_RUN_NUMBER + process.env.GITHUB_SHA = processValues.GITHUB_SHA + }) + + describe('detect', () => { + it('returns an empty resource', () => { + const resource = githubActionsDetectorSync.detect() + + expect(resource.attributes).to.be.empty + }) + }) + }) + + describe('defined values', () => { + const processValues: any = {} + + beforeEach(() => { + // cache values + processValues.GITHUB_ACTION = process.env.GITHUB_ACTION + processValues.GH_BRANCH = process.env.GH_BRANCH + processValues.GITHUB_REF = process.env.GITHUB_REF + processValues.GITHUB_RUN_NUMBER = process.env.GITHUB_RUN_NUMBER + processValues.GITHUB_SHA = process.env.GITHUB_SHA + + //reset values + process.env.GITHUB_ACTION = 'githubAction' + process.env.GH_BRANCH = 'ghBranch' + process.env.GITHUB_REF = 'ghRef' + process.env.GITHUB_RUN_NUMBER = 'ghRunNumber' + process.env.GITHUB_SHA = 'ghSha' + }) + + afterEach(() => { + // Replace values + process.env.GITHUB_ACTION = processValues.GITHUB_ACTION + process.env.GH_BRANCH = processValues.GH_BRANCH + process.env.GITHUB_REF = processValues.GITHUB_REF + process.env.GITHUB_RUN_NUMBER = processValues.GITHUB_RUN_NUMBER + process.env.GITHUB_SHA = processValues.GITHUB_SHA + }) + + describe('detect', () => { + it('returns a resource with attributes', () => { + const resource = githubActionsDetectorSync.detect() + + console.log(resource.attributes) + + expect(resource.attributes['ci.github_action']).to.equal('githubAction') + expect(resource.attributes['ci.branch']).to.equal('ghBranch') + expect(resource.attributes['ci.build-number']).to.equal('ghRunNumber') + expect(resource.attributes['SHA1']).to.equal('ghSha') + }) + + it('returns a resource with attributes when gh_branch is missing', () => { + delete process.env.GH_BRANCH + + const resource = githubActionsDetectorSync.detect() + + console.log(resource.attributes) + + expect(resource.attributes['ci.github_action']).to.equal('githubAction') + expect(resource.attributes['ci.branch']).to.equal('ghRef') + expect(resource.attributes['ci.build-number']).to.equal('ghRunNumber') + expect(resource.attributes['SHA1']).to.equal('ghSha') + }) + }) + }) +}) diff --git a/packages/telemetry/test/index.spec.ts b/packages/telemetry/test/index.spec.ts index b9527b51fa5c..cea57b096914 100644 --- a/packages/telemetry/test/index.spec.ts +++ b/packages/telemetry/test/index.spec.ts @@ -31,7 +31,7 @@ describe('init', () => { expect(tel.rootContext).to.be.undefined }) - it('creates a new instance', () => { + it('creates a new instance with root context', () => { const exporter = new OTLPTraceExporterCloud() const tel = new Telemetry({ @@ -40,12 +40,13 @@ describe('init', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' }, + rootContextObject: { context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' }, attributes: { yes: 'no' } }, SpanProcessor: BatchSpanProcessor, }) expect(tel).to.not.be.undefined expect(tel.rootContext).to.not.be.undefined + expect(tel.rootAttributes).to.not.be.undefined }) }) @@ -59,7 +60,7 @@ describe('startSpan', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' }, + rootContextObject: { context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' } }, SpanProcessor: BatchSpanProcessor, }) @@ -94,6 +95,29 @@ describe('startSpan', () => { expect(span.parentSpanId).to.be.undefined }) + it('starts a span with specific parent', () => { + const exporter = new OTLPTraceExporterCloud() + + const tel = new Telemetry({ + namespace: 'namespace', + Provider: NodeTracerProvider, + detectors: [], + exporter, + version: 'version', + SpanProcessor: BatchSpanProcessor, + }) + + const parentSpan = tel.startSpan({ name: 'parentSpan' }) + + const span = tel.startSpan({ name: 'span', parentSpan }) + + // @ts-expect-error + expect(span.name).to.equal('span') + // @ts-expect-error + expect(span.parentSpanId).to.equal(parentSpan._spanContext.spanId) + expect(tel.activeSpanQueue.length).to.be.lessThan(1) + }) + it('starts an active span', () => { const exporter = new OTLPTraceExporterCloud() @@ -136,6 +160,28 @@ describe('startSpan', () => { expect(tel.activeSpanQueue.length).to.be.lessThan(1) }) + + it('starts a span with key other than name', () => { + const exporter = new OTLPTraceExporterCloud() + + const tel = new Telemetry({ + namespace: 'namespace', + Provider: NodeTracerProvider, + detectors: [], + exporter, + version: 'version', + SpanProcessor: BatchSpanProcessor, + }) + + const span = tel.startSpan({ name: 'span', key: 'key' }) + + const retrievedSpan = tel.getSpan('key') + + // @ts-expect-error + expect(retrievedSpan.name).to.equal('span') + // @ts-expect-error + expect(retrievedSpan._spanContext.spanId).to.equal(span._spanContext.spanId) + }) }) describe('getSpan', () => { @@ -170,7 +216,7 @@ describe('findActiveSpan', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: 'id' }, + rootContextObject: { context: { traceparent: 'id' } }, SpanProcessor: BatchSpanProcessor, }) @@ -196,7 +242,7 @@ describe('endActiveSpanAndChildren', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: 'id' }, + rootContextObject: { context: { traceparent: 'id' } }, SpanProcessor: BatchSpanProcessor, }) @@ -228,19 +274,19 @@ describe('getActiveContextObject', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: 'id' }, + rootContextObject: { context: { traceparent: 'id' } }, SpanProcessor: BatchSpanProcessor, }) const emptyContext = tel.getActiveContextObject() - expect(emptyContext.traceparent).to.be.undefined + expect(emptyContext.context).to.be.undefined tel.startSpan({ name: 'spanny', active: true }) const context = tel.getActiveContextObject() - expect(context.traceparent).to.exist + expect(context.context.traceparent).to.exist }) }) @@ -330,14 +376,14 @@ describe('setRootContext', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' }, + rootContextObject: { context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' } }, SpanProcessor: BatchSpanProcessor, }) // @ts-expect-error expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0') - tel.setRootContext({ traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' }) + tel.setRootContext({ context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' } }) // @ts-expect-error expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b1') @@ -352,7 +398,7 @@ describe('setRootContext', () => { detectors: [], exporter, version: 'version', - rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' }, + rootContextObject: { context: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' } }, SpanProcessor: BatchSpanProcessor, }) diff --git a/packages/telemetry/test/node.spec.ts b/packages/telemetry/test/node.spec.ts index ae5137978a37..1f4e724839ea 100644 --- a/packages/telemetry/test/node.spec.ts +++ b/packages/telemetry/test/node.spec.ts @@ -50,7 +50,7 @@ describe('telemetry is disabled', () => { describe('getActiveContextObject', () => { it('returns an empty object', () => { - expect(telemetry.getActiveContextObject().traceparent).to.be.undefined + expect(telemetry.getActiveContextObject().context).to.be.undefined }) }) @@ -119,7 +119,7 @@ describe('telemetry is enabled', () => { expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw - expect(telemetry.getActiveContextObject().traceparent).to.be.undefined + expect(telemetry.getActiveContextObject().context).to.be.undefined }) }) @@ -127,7 +127,7 @@ describe('telemetry is enabled', () => { it('returns an empty object', () => { const spanny = telemetry.startSpan({ name: 'active', active: true }) - expect(telemetry.getActiveContextObject().traceparent).to.exist + expect(telemetry.getActiveContextObject().context.traceparent).to.exist spanny?.end() }) }) @@ -176,13 +176,13 @@ describe('telemetry is enabled', () => { describe('encode/decode', () => { it('encodes and decodes telemetry context', () => { const context = { - context: { traceparent: 'abc' }, + context: { context: { traceparent: 'abc' } }, version: '123', } const decodedContext = decodeTelemetryContext(encodeTelemetryContext(context)) - expect(decodedContext.context.traceparent).to.equal(context.context.traceparent) + expect(decodedContext.context.context.traceparent).to.equal(context.context.context.traceparent) expect(decodedContext.version).to.equal(context.version) }) diff --git a/packages/telemetry/test/processors/on-start-span-processor.spec.ts b/packages/telemetry/test/processors/on-start-span-processor.spec.ts new file mode 100644 index 000000000000..037af7ba2dc3 --- /dev/null +++ b/packages/telemetry/test/processors/on-start-span-processor.spec.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai' + +import { OnStartSpanProcessor } from '../../src/processors/on-start-span-processor' + +describe('on-start-span-processor', () => { + it('calls onEnd on start', (done) => { + const processor = new OnStartSpanProcessor(undefined) + + const span = 'span' + + processor.onEnd = (span) => { + expect(span).to.equal + done() + } + + //@ts-expect-error + processor.onStart(span, undefined) + }) +}) diff --git a/packages/telemetry/test/span-exporters/console-trace-link-exporter.spec.ts b/packages/telemetry/test/span-exporters/console-trace-link-exporter.spec.ts new file mode 100644 index 000000000000..1f24b6a59fca --- /dev/null +++ b/packages/telemetry/test/span-exporters/console-trace-link-exporter.spec.ts @@ -0,0 +1,168 @@ +import { expect } from 'chai' + +import { ConsoleTraceLinkExporter } from '../../src/span-exporters/console-trace-link-exporter' + +describe('consoleTraceLinkExporter', () => { + describe('new', () => { + it('sets up trace url', () => { + const exporter = new ConsoleTraceLinkExporter({ + serviceName: 'serviceName', + team: 'team', + environment: 'environment', + }) + + //@ts-expect-error + expect(exporter._traceUrl).to.equal('https://ui.honeycomb.io/team/environments/environment/datasets/serviceName/trace?trace_id') + }) + }) + + describe('export', () => { + it('logs the start of the first span with a unique trace', (done) => { + const exporter = new ConsoleTraceLinkExporter({ + serviceName: 'serviceName', + team: 'team', + environment: 'environment', + }) + + //@ts-expect-error + exporter._log = (...args) => { + expect(args[0]).to.equal('Trace start: [spanName] - https://ui.honeycomb.io/team/environments/environment/datasets/serviceName/trace?trace_id=traceId') + } + + exporter.export([{ + name: 'spanName', + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId', + } + }, + }], (result) => { + //@ts-expect-error + expect(exporter._uniqueTraces['traceId']).to.equal('spanId') + expect(result.code).to.equal(0) + done() + }) + }) + + it('ignores the start of the second span with a unique trace', (done) => { + const exporter = new ConsoleTraceLinkExporter({ + serviceName: 'serviceName', + team: 'team', + environment: 'environment', + }) + + exporter.export([{ + name: 'spanName', + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId', + } + }, + }], () => {}) + + //@ts-expect-error + exporter._log = (...args) => { + throw 'do not call' + } + + exporter.export([{ + name: 'spanName', + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId2', + } + }, + }], (result) => { + //@ts-expect-error + expect(exporter._uniqueTraces['traceId']).to.not.equal('spanId2') + expect(result.code).to.equal(0) + done() + }) + }) + + it('ignores the end of the second span with a unique trace', (done) => { + const exporter = new ConsoleTraceLinkExporter({ + serviceName: 'serviceName', + team: 'team', + environment: 'environment', + }) + + exporter.export([{ + name: 'spanName', + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId', + } + }, + }], () => {}) + + //@ts-expect-error + exporter._log = (...args) => { + throw 'do not call' + } + + exporter.export([{ + name: 'spanName', + ended: true, + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId2', + } + }, + }], (result) => { + expect(result.code).to.equal(0) + done() + }) + }) + + it('logs the end of the first span with a unique trace', (done) => { + const exporter = new ConsoleTraceLinkExporter({ + serviceName: 'serviceName', + team: 'team', + environment: 'environment', + }) + + exporter.export([{ + name: 'spanName', + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId', + } + }, + }], () => {}) + + //@ts-expect-error + exporter._log = (...args) => { + console.log(args) + expect(args[0]).to.equal('Trace end: [spanName] - https://ui.honeycomb.io/team/environments/environment/datasets/serviceName/trace?trace_id=traceId') + } + + exporter.export([{ + name: 'spanName', + ended: true, + //@ts-expect-error + spanContext: () => { + return { + traceId: 'traceId', + spanId: 'spanId', + } + }, + }], (result) => { + expect(result.code).to.equal(0) + done() + }) + }) + }) +}) diff --git a/tooling/v8-snapshot/cache/linux/snapshot-meta.json b/tooling/v8-snapshot/cache/linux/snapshot-meta.json index a4f960d279a2..7a597a6b68f7 100644 --- a/tooling/v8-snapshot/cache/linux/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/linux/snapshot-meta.json @@ -4727,7 +4727,9 @@ "./packages/telemetry/dist/detectors/circleCiDetectorSync.js", "./packages/telemetry/dist/index.js", "./packages/telemetry/dist/node.js", + "./packages/telemetry/dist/processors/on-start-span-processor.js", "./packages/telemetry/dist/span-exporters/cloud-span-exporter.js", + "./packages/telemetry/dist/span-exporters/console-trace-link-exporter.js", "./packages/telemetry/dist/span-exporters/ipc-span-exporter.js", "./packages/ts/registerDir.js", "./packages/types/src/auth.ts",