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",