diff --git a/docs/config/experimental.md b/docs/config/experimental.md index 61797a690cdf..4dc627fdff37 100644 --- a/docs/config/experimental.md +++ b/docs/config/experimental.md @@ -117,9 +117,13 @@ Please, leave feedback regarding this feature in a [GitHub Discussion](https://g interface OpenTelemetryOptions { enabled: boolean /** - * A path to a file that exposes an OpenTelemetry SDK. + * A path to a file that exposes an OpenTelemetry SDK for Node.js. */ sdkPath?: string + /** + * A path to a file that exposes an OpenTelemetry SDK for the browser. + */ + browserSdkPath?: string } ``` @@ -133,9 +137,7 @@ OpenTelemetry may significantly impact Vitest performance; enable it only for lo You can use a [custom service](/guide/open-telemetry) together with Vitest to pinpoint which tests or files are slowing down your test suite. -::: warning BROWSER SUPPORT -At the moment, Vitest does not start any spans when running in [the browser](/guide/browser/). -::: +For browser mode, see the [Browser Mode](/guide/open-telemetry#browser-mode) section of the OpenTelemetry guide. An `sdkPath` is resolved relative to the [`root`](/config/root) of the project and should point to a module that exposes a started SDK instance as a default export. For example: diff --git a/docs/guide/open-telemetry.md b/docs/guide/open-telemetry.md index 2da29046c299..63168f07b5c4 100644 --- a/docs/guide/open-telemetry.md +++ b/docs/guide/open-telemetry.md @@ -84,6 +84,59 @@ test('db connects properly', async () => { }) ``` +## Browser Mode + +When running tests in [browser mode](/guide/browser/), Vitest propagates trace context between Node.js and the browser. Node.js side traces (test orchestration, browser driver communication) are available without additional configuration. + +To capture traces from the browser runtime, provide a browser-compatible SDK via `browserSdkPath`: + +```shell +npm i @opentelemetry/sdk-trace-web @opentelemetry/exporter-trace-otlp-proto +``` + +::: code-group +```js [otel-browser.js] +import { + BatchSpanProcessor, + WebTracerProvider, +} from '@opentelemetry/sdk-trace-web' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' + +const provider = new WebTracerProvider({ + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter()), + ], +}) + +provider.register() +export default provider +``` +```js [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }], + }, + experimental: { + openTelemetry: { + enabled: true, + sdkPath: './otel.js', + browserSdkPath: './otel-browser.js', + }, + }, + }, +}) +``` +::: + +::: warning ASYNC CONTEXT +Unlike Node.js, browsers do not have automatic async context propagation. Vitest handles this internally for test execution, but custom spans in deeply nested async code may not propagate context automatically. +::: + ## View Traces To generate traces, run Vitest as usual. You can run Vitest in either watch mode or run mode. Vitest will call `sdk.shutdown()` manually after everything is finished to make sure traces are handled properly. diff --git a/examples/opentelemetry/jaeger-config.yml b/examples/opentelemetry/jaeger-config.yml index bb629b7d147a..714bfc00b234 100644 --- a/examples/opentelemetry/jaeger-config.yml +++ b/examples/opentelemetry/jaeger-config.yml @@ -14,13 +14,15 @@ extensions: storage: traces: main_storage +# https://opentelemetry.io/docs/languages/js/exporters/#configure-cors-headers receivers: otlp: protocols: - grpc: - endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 + cors: + allowed_origins: + - http://localhost:* processors: batch: diff --git a/examples/opentelemetry/otel-browser.js b/examples/opentelemetry/otel-browser.js new file mode 100644 index 000000000000..e5d104400b89 --- /dev/null +++ b/examples/opentelemetry/otel-browser.js @@ -0,0 +1,24 @@ +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { resourceFromAttributes } from '@opentelemetry/resources' +import { BatchSpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web' + +const provider = new WebTracerProvider({ + resource: resourceFromAttributes({ + 'service.name': 'vitest-browser', + }), + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter()), + // you can add a ConsoleSpanExporter for debugging purposes + // (available in @opentelemetry/sdk-trace-web) + // new SimpleSpanProcessor(new ConsoleSpanExporter()), + ], +}) + +provider.register({ + // you can customize contextManager but browser support has limitation + // cf. https://github.com/open-telemetry/opentelemetry-js/discussions/2060 + // contextManager: new StackContextManager(), // this is the default (avialable in sdk-trace-web) + // contextManager: new ZoneContextManager(), // doesn't seem to help (avialable in @opentelemetry/context-zone) +}) + +export default provider diff --git a/examples/opentelemetry/package.json b/examples/opentelemetry/package.json index 0a9cbaca647e..6aa013e4ea88 100644 --- a/examples/opentelemetry/package.json +++ b/examples/opentelemetry/package.json @@ -8,7 +8,12 @@ "test": "vitest" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-zone": "^2.2.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-node": "^0.208.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", "@vitest/browser-playwright": "latest", "vite": "latest", "vitest": "latest" diff --git a/examples/opentelemetry/src/other.test.ts b/examples/opentelemetry/src/other.test.ts new file mode 100644 index 000000000000..353c2ebfe3f1 --- /dev/null +++ b/examples/opentelemetry/src/other.test.ts @@ -0,0 +1,31 @@ +import { trace } from '@opentelemetry/api' +import { test } from 'vitest' + +test('other', async () => { + await new Promise(r => setTimeout(r, 150)) +}) + +test('custom', async () => { + // this starts span synchronously inside test function, + // so trace parent works without async context manager (e.g. on browser mode). + + // console.log(context.active()) + // > vitest.test.runner.test.callback + // > custom-span + + const tracer = trace.getTracer('custom-scope') + await tracer.startActiveSpan('custom-span', async (span) => { + span.setAttribute('custom-attribute', 'hello world') + await new Promise(resolve => setTimeout(resolve, 50)) + span.end() + }) + + // however the context is dropped on browser mode. + // console.log(context.active()) +}) + +test.runIf(typeof document !== 'undefined')('browser test', async () => { + const { page } = await import('vitest/browser') + document.body.innerHTML = `` + await page.getByRole('button', { name: 'Hello Vitest' }).click() +}) diff --git a/examples/opentelemetry/vite.config.ts b/examples/opentelemetry/vite.config.ts index 969493536a37..54e226fc9e52 100644 --- a/examples/opentelemetry/vite.config.ts +++ b/examples/opentelemetry/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ // enable via CLI flag --experimental.openTelemetry.enabled=true enabled: false, sdkPath: './otel.js', + browserSdkPath: './otel-browser.js', }, }, browser: { diff --git a/packages/browser/package.json b/packages/browser/package.json index 6e4c2c562683..c46f419b6827 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -56,7 +56,7 @@ "build": "premove dist && pnpm build:node && pnpm build:client", "build:client": "vite build src/client", "build:node": "rollup -c", - "dev:client": "vite build src/client --watch", + "dev:client": "node --watch-preserve-output --watch-path src/client scripts/build-client.js", "dev:node": "rollup -c --watch --watch.include 'src/**'", "dev": "premove dist && pnpm run --stream '/^dev:/'" }, @@ -74,6 +74,7 @@ "ws": "catalog:" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@testing-library/user-event": "^14.6.1", "@types/pngjs": "^6.0.5", "@types/ws": "catalog:", diff --git a/packages/browser/scripts/build-client.js b/packages/browser/scripts/build-client.js new file mode 100644 index 000000000000..1da2641375b6 --- /dev/null +++ b/packages/browser/scripts/build-client.js @@ -0,0 +1,7 @@ +// wrapper script for `node --watch`. +// this works around some issues with `vite build --watch`. +import { spawn } from 'node:child_process' + +spawn('node', ['--run', 'build:client'], { + stdio: 'inherit', +}) diff --git a/packages/browser/src/client/channel.ts b/packages/browser/src/client/channel.ts index dd3cd5f2d0b9..e8cff74da8eb 100644 --- a/packages/browser/src/client/channel.ts +++ b/packages/browser/src/client/channel.ts @@ -1,4 +1,5 @@ import type { CancelReason, FileSpecification } from '@vitest/runner' +import type { OTELCarrier } from 'vitest/internal/browser' import { getBrowserState } from './utils' export interface IframeViewportEvent { @@ -41,6 +42,7 @@ export interface IframePrepareEvent { event: 'prepare' iframeId: string startTime: number + otelCarrier?: OTELCarrier } export type GlobalChannelIncomingEvent = GlobalChannelTestRunCanceledEvent diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 918f7bc17f7a..675d9331401d 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -1,9 +1,11 @@ +import type { Context as OTELContext } from '@opentelemetry/api' import type { GlobalChannelIncomingEvent, IframeChannelIncomingEvent, IframeChannelOutgoingEvent, IframeViewportDoneEvent, IframeViewportFailEvent } from '@vitest/browser/client' import type { FileSpecification } from '@vitest/runner' import type { BrowserTesterOptions, SerializedConfig } from 'vitest' import { channel, client, globalChannel } from '@vitest/browser/client' import { generateFileHash } from '@vitest/runner/utils' import { relative } from 'pathe' +import { Traces } from 'vitest/internal/browser' import { getUiAPI } from './ui' import { getBrowserState, getConfig } from './utils' @@ -16,9 +18,17 @@ export class IframeOrchestrator { public eventTarget: EventTarget = new EventTarget() + private traces: Traces + constructor() { debug('init orchestrator', getBrowserState().sessionId) + const otelConfig = getBrowserState().config.experimental.openTelemetry + this.traces = new Traces({ + enabled: !!(otelConfig?.enabled && otelConfig.browserSdkPath), + sdkPath: `/@fs/${otelConfig?.browserSdkPath}`, + }) + channel.addEventListener( 'message', e => this.onIframeEvent(e), @@ -30,6 +40,24 @@ export class IframeOrchestrator { } public async createTesters(options: BrowserTesterOptions): Promise { + await this.traces.waitInit() + this.traces.recordInitSpan( + this.traces.getContextFromCarrier(getBrowserState().otelCarrier), + ) + const orchestratorSpan = this.traces.startContextSpan( + 'vitest.browser.orchestrator.run', + this.traces.getContextFromCarrier(options.otelCarrier), + ) + orchestratorSpan.span.setAttributes({ + 'vitest.browser.files': options.files.map(f => f.filepath), + }) + const endSpan = async () => { + orchestratorSpan.span.end() + // orchestrator doesn't know specific timing when it gets torn down, + // so we ensure flushing traces here after each run + await this.traces.flush() + } + const startTime = performance.now() this.cancelled = false @@ -49,7 +77,8 @@ export class IframeOrchestrator { } if (config.browser.isolate === false) { - await this.runNonIsolatedTests(container, options, startTime) + await this.runNonIsolatedTests(container, options, startTime, orchestratorSpan.context) + await endSpan() return } @@ -58,6 +87,7 @@ export class IframeOrchestrator { for (let i = 0; i < options.files.length; i++) { if (this.cancelled) { + await endSpan() return } @@ -69,8 +99,10 @@ export class IframeOrchestrator { file, options, startTime, + orchestratorSpan.context, ) } + await endSpan() } public async cleanupTesters(): Promise { @@ -99,7 +131,12 @@ export class IframeOrchestrator { this.recreateNonIsolatedIframe = true } - private async runNonIsolatedTests(container: HTMLDivElement, options: BrowserTesterOptions, startTime: number) { + private async runNonIsolatedTests( + container: HTMLDivElement, + options: BrowserTesterOptions, + startTime: number, + otelContext: OTELContext, + ) { if (this.recreateNonIsolatedIframe) { // recreate a new non-isolated iframe during watcher reruns // because we called "cleanup" in the previous run @@ -112,7 +149,7 @@ export class IframeOrchestrator { if (!this.iframes.has(ID_ALL)) { debug('preparing non-isolated iframe') - await this.prepareIframe(container, ID_ALL, startTime) + await this.prepareIframe(container, ID_ALL, startTime, otelContext) } const config = getConfig() @@ -138,6 +175,7 @@ export class IframeOrchestrator { spec: FileSpecification, options: BrowserTesterOptions, startTime: number, + otelContext: OTELContext, ) { const config = getConfig() const { width, height } = config.browser.viewport @@ -149,7 +187,12 @@ export class IframeOrchestrator { this.iframes.delete(file) } - const iframe = await this.prepareIframe(container, file, startTime) + const iframe = await this.prepareIframe( + container, + file, + startTime, + otelContext, + ) await setIframeViewport(iframe, width, height) // running tests after the "prepare" event await sendEventToIframe({ @@ -172,7 +215,12 @@ export class IframeOrchestrator { return error } - private async prepareIframe(container: HTMLDivElement, iframeId: string, startTime: number) { + private async prepareIframe( + container: HTMLDivElement, + iframeId: string, + startTime: number, + otelContext: OTELContext, + ) { const iframe = this.createTestIframe(iframeId) container.appendChild(iframe) @@ -194,6 +242,7 @@ export class IframeOrchestrator { event: 'prepare', iframeId, startTime, + otelCarrier: this.traces.getContextCarrier(otelContext), }).then(resolve, error => reject(this.dispatchIframeError(error))) } } diff --git a/packages/browser/src/client/public/esm-client-injector.js b/packages/browser/src/client/public/esm-client-injector.js index 0e0a0d9d28dc..9570d521c7a6 100644 --- a/packages/browser/src/client/public/esm-client-injector.js +++ b/packages/browser/src/client/public/esm-client-injector.js @@ -38,6 +38,7 @@ type: { __VITEST_TYPE__ }, sessionId: { __VITEST_SESSION_ID__ }, testerId: { __VITEST_TESTER_ID__ }, + otelCarrier: { __VITEST_OTEL_CARRIER__ }, provider: { __VITEST_PROVIDER__ }, method: { __VITEST_METHOD__ }, providedContext: { __VITEST_PROVIDED_CONTEXT__ }, diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 37fb0db73a08..cd780b05a5ca 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -11,6 +11,9 @@ import type { VitestRunner, } from '@vitest/runner' import type { SerializedConfig, TestExecutionMethod, WorkerGlobalState } from 'vitest' +import type { + Traces, +} from 'vitest/internal/browser' import type { VitestBrowserClientMocker } from './mocker' import type { CommandsManager } from './tester-utils' import { globalChannel, onCancel } from '@vitest/browser/client' @@ -57,12 +60,14 @@ export function createBrowserRunner( public sourceMapCache = new Map() public method = 'run' as TestExecutionMethod private commands: CommandsManager + private _otel!: Traces constructor(options: BrowserRunnerOptions) { super(options.config) this.config = options.config this.commands = getBrowserState().commands this.viteEnvironment = '__browser__' + this._otel = getBrowserState().traces } setMethod(method: TestExecutionMethod) { @@ -296,9 +301,10 @@ export function createBrowserRunner( } } - // disable tracing in the browser for now - trace = undefined - __setTraces = undefined + trace = (name: string, attributes: Record | (() => T), cb?: () => T): T => { + const options: import('@opentelemetry/api').SpanOptions = typeof attributes === 'object' ? { attributes } : {} + return this._otel.$(`vitest.test.runner.${name}`, options, cb || attributes as () => T) + } } } diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index c22fef6c4c7c..9568214359bb 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -113,20 +113,30 @@ export class CommandsManager { ): Promise { const state = getWorkerState() const rpc = state.rpc as any as BrowserRPC - const { sessionId } = getBrowserState() + const { sessionId, traces } = getBrowserState() const filepath = state.filepath || state.current?.file?.filepath args = args.filter(arg => arg !== undefined) // remove optional fields if (this._listeners.length) { await Promise.all(this._listeners.map(listener => listener(command, args))) } - return rpc.triggerCommand(sessionId, command, filepath, args).catch((err) => { - // rethrow an error to keep the stack trace in browser - // const clientError = new Error(err.message) - clientError.message = err.message - clientError.name = err.name - clientError.stack = clientError.stack?.replace(clientError.message, err.message) - throw clientError - }) + return traces.$( + 'vitest.browser.tester.command', + { + attributes: { + 'vitest.browser.command': command, + 'code.file.path': filepath, + }, + }, + () => + rpc.triggerCommand(sessionId, command, filepath, args).catch((err) => { + // rethrow an error to keep the stack trace in browser + // const clientError = new Error(err.message) + clientError.message = err.message + clientError.name = err.name + clientError.stack = clientError.stack?.replace(clientError.message, err.message) + throw clientError + }), + ) } } diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index 54234d06cb36..db415b6d70b9 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -10,6 +10,7 @@ import { startCoverageInsideWorker, startTests, stopCoverageInsideWorker, + Traces, } from 'vitest/internal/browser' import { getBrowserState, getConfig, getWorkerState, moduleRunner } from '../utils' import { setupDialogsSpy } from './dialog' @@ -25,6 +26,14 @@ const debug = debugVar && debugVar !== 'false' ? (...args: unknown[]) => client.rpc.debug?.(...args.map(String)) : undefined +const otelConfig = getConfig().experimental.openTelemetry +const traces = new Traces({ + enabled: !!(otelConfig?.enabled && otelConfig?.browserSdkPath), + sdkPath: `/@fs/${otelConfig?.browserSdkPath}`, +}) +let rootTesterSpan: ReturnType | undefined +getBrowserState().traces = traces + channel.addEventListener('message', async (e) => { await client.waitForConnection() @@ -61,9 +70,19 @@ channel.addEventListener('message', async (e) => { } case 'cleanup': { await cleanup().catch(err => unhandledError(err, 'Cleanup Error')) + rootTesterSpan?.span.end() + await traces.finish() break } case 'prepare': { + await traces.waitInit() + const tracesContext = traces.getContextFromCarrier(data.otelCarrier) + traces.recordInitSpan(tracesContext) + rootTesterSpan = traces.startContextSpan( + `vitest.browser.tester.run`, + tracesContext, + ) + traces.bind(rootTesterSpan.context) await prepare(data).catch(err => unhandledError(err, 'Prepare Error')) break } @@ -182,12 +201,19 @@ async function executeTests(method: 'run' | 'collect', specifications: FileSpeci state.filepath = file.filepath debug?.('running test file', file.filepath) - if (method === 'run') { - await startTests([file], runner) - } - else { - await collectTests([file], runner) - } + await traces.$( + `vitest.test.runner.${method}.module`, + { attributes: { 'code.file.path': file.filepath }, + }, + async () => { + if (method === 'run') { + await startTests([file], runner) + } + else { + await collectTests([file], runner) + } + }, + ) } } diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 5c1f9501ef53..860b59d7c1b3 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -1,5 +1,6 @@ import type { VitestRunner } from '@vitest/runner' import type { SerializedConfig, WorkerGlobalState } from 'vitest' +import type { OTELCarrier, Traces } from 'vitest/internal/browser' import type { IframeOrchestrator } from './orchestrator' import type { CommandsManager } from './tester/tester-utils' @@ -78,9 +79,11 @@ export interface BrowserRunnerState { iframeId?: string sessionId: string testerId: string + otelCarrier?: OTELCarrier method: 'run' | 'collect' orchestrator?: IframeOrchestrator commands: CommandsManager + traces: Traces cleanups: Array<() => unknown> cdp?: { on: (event: string, listener: (payload: any) => void) => void diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 765d24c02066..29baa659239a 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -325,6 +325,12 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => { include.push('@vue/test-utils') } + const otelConfig = project.config.experimental.openTelemetry + if (otelConfig?.enabled && otelConfig.browserSdkPath) { + entries.push(otelConfig.browserSdkPath) + include.push('@opentelemetry/api') + } + return { define, resolve: { diff --git a/packages/browser/src/node/serverOrchestrator.ts b/packages/browser/src/node/serverOrchestrator.ts index 0d1d5b73b8c8..e9018d9a48c6 100644 --- a/packages/browser/src/node/serverOrchestrator.ts +++ b/packages/browser/src/node/serverOrchestrator.ts @@ -45,6 +45,7 @@ export async function resolveOrchestrator( __VITEST_TYPE__: '"orchestrator"', __VITEST_SESSION_ID__: JSON.stringify(sessionId), __VITEST_TESTER_ID__: '"none"', + __VITEST_OTEL_CARRIER__: url.searchParams.get('otelCarrier') ?? 'null', __VITEST_PROVIDED_CONTEXT__: JSON.stringify(stringify(browserProject.project.getProvidedContext())), __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), }) diff --git a/packages/browser/src/node/serverTester.ts b/packages/browser/src/node/serverTester.ts index 92603616b07d..a2891ed34b8d 100644 --- a/packages/browser/src/node/serverTester.ts +++ b/packages/browser/src/node/serverTester.ts @@ -53,6 +53,7 @@ export async function resolveTester( __VITEST_TYPE__: '"tester"', __VITEST_METHOD__: JSON.stringify('none'), __VITEST_SESSION_ID__: JSON.stringify(sessionId), + __VITEST_OTEL_CARRIER__: JSON.stringify(null), __VITEST_TESTER_ID__: JSON.stringify(crypto.randomUUID()), __VITEST_PROVIDED_CONTEXT__: '{}', __VITEST_API_TOKEN__: JSON.stringify(globalServer.vitest.config.api.token), diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index cc91e3651d8f..45e99c651dcb 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -810,6 +810,13 @@ export function resolveConfig( ) resolved.experimental.openTelemetry.sdkPath = pathToFileURL(sdkPath).toString() } + if (resolved.experimental.openTelemetry?.browserSdkPath) { + const browserSdkPath = resolve( + resolved.root, + resolved.experimental.openTelemetry.browserSdkPath, + ) + resolved.experimental.openTelemetry.browserSdkPath = browserSdkPath + } if (resolved.experimental.fsModuleCachePath) { resolved.experimental.fsModuleCachePath = resolve( resolved.root, diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 46b511afb49f..44943686bffc 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -133,6 +133,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { experimental: { fsModuleCache: config.experimental.fsModuleCache ?? false, printImportBreakdown: config.experimental.printImportBreakdown, + openTelemetry: config.experimental.openTelemetry, }, } } diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 920ab1a5cddb..03bd82fa6c6c 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -1,5 +1,7 @@ +import type { Context, Span } from '@opentelemetry/api' import type { FileSpecification } from '@vitest/runner' import type { DeferPromise } from '@vitest/utils/helpers' +import type { Traces } from '../../utils/traces' import type { Vitest } from '../core' import type { ProcessPool } from '../pool' import type { TestProject } from '../project' @@ -176,16 +178,30 @@ class BrowserPool { private readySessions = new Set() + private _traces: Traces + private _otel: { + span: Span + context: Context + } + constructor( private project: TestProject, private options: { maxWorkers: number origin: string }, - ) {} + ) { + this._traces = project.vitest._traces + this._otel = this._traces.startContextSpan('vitest.browser') + this._otel.span.setAttributes({ + 'vitest.project': project.name, + 'vitest.browser.provider': this.project.browser!.provider.name, + }) + } public cancel(): void { this._queue = [] + this._otel.span.end() } public reject(error: Error): void { @@ -236,7 +252,17 @@ class BrowserPool { this.project.vitest._browserSessions.sessionIds.add(sessionId) const project = this.project.name debug?.('[%s] creating session for %s', sessionId, project) - const page = this.openPage(sessionId).then(() => { + let page = this._traces.$( + `vitest.browser.open`, + { + context: this._otel.context, + attributes: { + 'vitest.browser.session_id': sessionId, + }, + }, + () => this.openPage(sessionId), + ) + page = page.then(() => { // start running tests on the page when it's ready this.runNextTest(method, sessionId) }) @@ -256,6 +282,10 @@ class BrowserPool { const browser = this.project.browser! const url = new URL('/__vitest_test__/', this.options.origin) url.searchParams.set('sessionId', sessionId) + const otelCarrier = this._traces.getContextCarrier() + if (otelCarrier) { + url.searchParams.set('otelCarrier', JSON.stringify(otelCarrier)) + } const pagePromise = browser.provider.openPage( sessionId, url.toString(), @@ -276,6 +306,7 @@ class BrowserPool { // the last worker finished running tests if (this.readySessions.size === this.orchestrators.size) { + this._otel.span.end() this._promise?.resolve() this._promise = undefined debug?.('[%s] all tests finished running', sessionId) @@ -320,15 +351,28 @@ class BrowserPool { this.setBreakpoint(sessionId, file.filepath).then(() => { // this starts running tests inside the orchestrator - orchestrator.createTesters( + const testersPromise = this._traces.$( + `vitest.browser.run`, { - method, - files: [file], - // this will be parsed by the test iframe, not the orchestrator - // so we need to stringify it first to avoid double serialization - providedContext: this._providedContext || '[{}]', + context: this._otel.context, + attributes: { + 'code.file.path': file.filepath, + }, + }, + async () => { + return orchestrator.createTesters( + { + method, + files: [file], + // this will be parsed by the test iframe, not the orchestrator + // so we need to stringify it first to avoid double serialization + providedContext: this._providedContext || '[{}]', + otelCarrier: this._traces.getContextCarrier(), + }, + ) }, ) + testersPromise .then(() => { debug?.('[%s] test %s finished running', sessionId, file) this.runNextTest(method, sessionId) diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 412b9f7ce035..fcd1f881083e 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -847,6 +847,7 @@ export interface InlineConfig { openTelemetry?: { enabled: boolean sdkPath?: string + browserSdkPath?: string } /** * Show imports (top 10) that take a long time. diff --git a/packages/vitest/src/public/browser.ts b/packages/vitest/src/public/browser.ts index 930e99f52226..64ee91c05172 100644 --- a/packages/vitest/src/public/browser.ts +++ b/packages/vitest/src/public/browser.ts @@ -8,6 +8,7 @@ export { loadSnapshotSerializers, setupCommonEnv, } from '../runtime/setup-common' +export { type OTELCarrier, Traces } from '../utils/traces' export { collectTests, startTests } from '@vitest/runner' export * as SpyModule from '@vitest/spy' export type { LoupeOptions, ParsedStack, StringifyOptions } from '@vitest/utils' diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 13e09ce403a9..1e7cf9561eb9 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -120,6 +120,11 @@ export interface SerializedConfig { experimental: { fsModuleCache: boolean printImportBreakdown: boolean | undefined + openTelemetry: { + enabled: boolean + sdkPath?: string + browserSdkPath?: string + } | undefined } } diff --git a/packages/vitest/src/runtime/workers/init.ts b/packages/vitest/src/runtime/workers/init.ts index c97402e1d1c4..e992e2085064 100644 --- a/packages/vitest/src/runtime/workers/init.ts +++ b/packages/vitest/src/runtime/workers/init.ts @@ -45,21 +45,16 @@ export function init(worker: Options): void { process.env.VITEST_WORKER_ID = String(message.workerId) reportMemory = message.options.reportMemory - const tracesStart = performance.now() - traces ??= await new Traces({ enabled: message.traces.enabled, sdkPath: message.traces.sdkPath, }).waitInit() - const tracesEnd = performance.now() const { environment, config, pool } = message.context const context = traces.getContextFromCarrier(message.traces.otelCarrier) // record telemetry as part of "start" - traces - .startSpan('vitest.runtime.traces', { startTime: tracesStart }, context) - .end(tracesEnd) + traces.recordInitSpan(context) try { const rpc = createRuntimeRpc(worker) diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 442a1961c1e0..8e8e704b5c62 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -1,8 +1,10 @@ import type { FileSpecification } from '@vitest/runner' +import type { OTELCarrier } from '../utils/traces' import type { TestExecutionMethod } from './worker' export interface BrowserTesterOptions { method: TestExecutionMethod files: FileSpecification[] providedContext: string + otelCarrier?: OTELCarrier } diff --git a/packages/vitest/src/utils/traces.ts b/packages/vitest/src/utils/traces.ts index 71e3d09c80bb..062b316b9bb3 100644 --- a/packages/vitest/src/utils/traces.ts +++ b/packages/vitest/src/utils/traces.ts @@ -41,10 +41,13 @@ export class Traces { * otel stands for OpenTelemetry */ #otel: OTEL | null = null - #sdk: { shutdown: () => Promise } | null = null + #sdk: { shutdown: () => Promise; forceFlush?: () => Promise } | null = null #init: Promise | null = null #noopSpan = createNoopSpan() #noopContext = createNoopContext() + #initStartTime = performance.now() + #initEndTime = 0 + #initRecorded = false constructor(options: TracesOptions) { if (options.enabled) { @@ -61,7 +64,7 @@ export class Traces { }).catch(() => { throw new Error(`"@opentelemetry/api" is not installed locally. Make sure you have setup OpenTelemetry instrumentation: https://vitest.dev/guide/open-telemetry`) }) - const sdkInit = (options.sdkPath ? import(options.sdkPath!) : Promise.resolve()).catch((cause) => { + const sdkInit = (options.sdkPath ? import(/* @vite-ignore */ options.sdkPath!) : Promise.resolve()).catch((cause) => { throw new Error(`Failed to import custom OpenTelemetry SDK script (${options.sdkPath}): ${cause.message}`) }) this.#init = Promise.all([sdkInit, apiInit]).then(([sdk]) => { @@ -74,6 +77,7 @@ export class Traces { } } }).finally(() => { + this.#initEndTime = performance.now() this.#init = null }) } @@ -93,6 +97,19 @@ export class Traces { return this } + /** + * @internal + */ + recordInitSpan(context: Context): void { + if (this.#initRecorded) { + return + } + this.#initRecorded = true + this + .startSpan('vitest.runtime.traces', { startTime: this.#initStartTime }, context) + .end(this.#initEndTime) + } + /** * @internal */ @@ -235,12 +252,34 @@ export class Traces { return tracer.startSpan(name, options, context) } + // On browser mode, async context is not automatically propagated, + // so we manually bind the `$` calls to the provided context. + // TODO: this doesn't bind to user land's `@optelemetry/api` calls + /** + * @internal + */ + bind(context: Context) { + if (!this.#otel) { + return + } + const original = (this.$ as any).__original ?? this.$ + this.$ = this.#otel.context.bind(context, original) + ;(this.$ as any).__original = original + } + /** * @internal */ async finish(): Promise { await this.#sdk?.shutdown() } + + /** + * @internal + */ + async flush(): Promise { + await this.#sdk?.forceFlush?.() + } } function noopSpan(this: Span) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2103754dbee4..00ea0bcdb3ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -372,9 +372,24 @@ importers: examples/opentelemetry: devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/context-zone': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.208.0 + version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': specifier: ^0.208.0 version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) '@vitest/browser-playwright': specifier: workspace:* version: link:../../packages/browser-playwright @@ -481,6 +496,9 @@ importers: specifier: 'catalog:' version: 8.18.3 devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -1181,9 +1199,15 @@ importers: test/cli: devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@opentelemetry/sdk-node': specifier: ^0.208.0 version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': + specifier: ^2.2.0 + version: 2.2.0(@opentelemetry/api@1.9.0) '@test/test-dep-error': specifier: file:./deps/error version: file:test/cli/deps/error @@ -3101,6 +3125,17 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/context-zone-peer-dep@2.2.0': + resolution: {integrity: sha512-/jSqc9MDpI7abRYNoM77G7xrJL8RhvOoQzmWg4Exj642NN1+ZwsqW0EODgaR99/w06nS2IGgY7AJRt5eZY/6QQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + zone.js: ^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 + + '@opentelemetry/context-zone@2.2.0': + resolution: {integrity: sha512-Wq0nUuRyVBmXIeISO1Sg9yTz+mUypCGjwGHSPR9iaY4f+n+F728+5hh85lko6fnm/oJAiKhmSmvvH/o8PhSUnw==} + engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/core@2.2.0': resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -3245,6 +3280,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace-web@2.2.0': + resolution: {integrity: sha512-x/LHsDBO3kfqaFx5qSzBljJ5QHsRXrvS4MybBDy1k7Svidb8ZyIPudWVzj3s5LpPkYZIgi9e+7tdsNCnptoelw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.38.0': resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} engines: {node: '>=14'} @@ -9698,6 +9739,9 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zone.js@0.15.1: + resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -11285,6 +11329,18 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-zone-peer-dep@2.2.0(@opentelemetry/api@1.9.0)(zone.js@0.15.1)': + dependencies: + '@opentelemetry/api': 1.9.0 + zone.js: 0.15.1 + + '@opentelemetry/context-zone@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/context-zone-peer-dep': 2.2.0(@opentelemetry/api@1.9.0)(zone.js@0.15.1) + zone.js: 0.15.1 + transitivePeerDependencies: + - '@opentelemetry/api' + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -11500,6 +11556,12 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions@1.38.0': {} '@oxc-minify/binding-android-arm64@0.99.0': @@ -18761,6 +18823,8 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.1.0 + zone.js@0.15.1: {} + zustand@4.5.7(@types/react@19.2.7)(react@19.2.0): dependencies: use-sync-external-store: 1.4.0(react@19.2.0) diff --git a/test/cli/fixtures/otel-tests/otel.browser.sdk.js b/test/cli/fixtures/otel-tests/otel.browser.sdk.js new file mode 100644 index 000000000000..7f58f0656b16 --- /dev/null +++ b/test/cli/fixtures/otel-tests/otel.browser.sdk.js @@ -0,0 +1,13 @@ +import { + WebTracerProvider, + ConsoleSpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-web' + +const provider = new WebTracerProvider({ + spanProcessors: [ + new SimpleSpanProcessor(new ConsoleSpanExporter()), + ], +}) +provider.register() +export default provider diff --git a/test/cli/package.json b/test/cli/package.json index b5bf52d7d6f4..324cacd3240b 100644 --- a/test/cli/package.json +++ b/test/cli/package.json @@ -7,7 +7,9 @@ "stacktraces": "vitest --root=./fixtures/stacktraces --watch=false" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", "@opentelemetry/sdk-node": "^0.208.0", + "@opentelemetry/sdk-trace-web": "^2.2.0", "@test/test-dep-error": "file:./deps/error", "@test/test-dep-linked": "link:./deps/linked", "@types/ws": "catalog:", diff --git a/test/cli/test/open-telemetry.test.ts b/test/cli/test/open-telemetry.test.ts index c269c5e2766c..9a7448202eb6 100644 --- a/test/cli/test/open-telemetry.test.ts +++ b/test/cli/test/open-telemetry.test.ts @@ -1,3 +1,4 @@ +import type { TestUserConfig } from 'vitest/node' import { playwright } from '@vitest/browser-playwright' import { test } from 'vitest' import { runVitest } from '../../test-utils' @@ -20,13 +21,25 @@ describe.for([ instances: [{ browser: 'chromium' as const }], }, }, + { + name: 'browser-sdk', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [{ browser: 'chromium' as const }], + }, + }, ])('$name doesn\'t crash vitest', async (custom) => { - const config = { + const config: TestUserConfig = { ...custom, experimental: { openTelemetry: { enabled: true, sdkPath: './otel.sdk.js', + browserSdkPath: custom.name === 'browser-sdk' + ? './otel.browser.sdk.js' + : undefined, }, }, } diff --git a/test/core/test/exports.test.ts b/test/core/test/exports.test.ts index 387fc6bcd75e..f26e9ff76104 100644 --- a/test/core/test/exports.test.ts +++ b/test/core/test/exports.test.ts @@ -230,6 +230,7 @@ it('exports snapshot', async ({ skip, task }) => { "./internal/browser": { "DecodedMap": "function", "SpyModule": "object", + "Traces": "function", "__INTERNAL": "object", "browserFormat": "function", "collectTests": "function",