Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
401a8d3
feat: support openTelemetry for browser mode (wip)
hi-ogawa Dec 4, 2025
1b9169a
chore: add examples/opentelemetry
hi-ogawa Dec 5, 2025
063e192
vitest.browser.open
hi-ogawa Dec 5, 2025
adc451c
vitest.browser.run
hi-ogawa Dec 5, 2025
6cdad2a
cleanup
hi-ogawa Dec 5, 2025
527b41d
Merge branch 'main' into 12-04-feat_support_opentelemetry_for_browser…
hi-ogawa Dec 8, 2025
8057c1d
chore: tweak examples
hi-ogawa Dec 8, 2025
5d0d333
tweak
hi-ogawa Dec 8, 2025
8b0885f
Merge branch 'main' into 12-04-feat_support_opentelemetry_for_browser…
hi-ogawa Dec 9, 2025
7d5f86d
span "vitest.browser"
hi-ogawa Dec 9, 2025
830048b
trace `vitest.browser.orchestrator.iframe`
hi-ogawa Dec 9, 2025
74018e0
browserSdkPath
hi-ogawa Dec 9, 2025
4ee2b43
exports.test
hi-ogawa Dec 9, 2025
c68ffbd
wip: trace on tester
hi-ogawa Dec 9, 2025
aad5157
fix: bind root tester context
hi-ogawa Dec 9, 2025
c4d2eae
cleanup .claude/issue-9043.md
hi-ogawa Dec 9, 2025
997b706
example BatchSpanProcessor
hi-ogawa Dec 9, 2025
b20711b
refactor: orchestrator traces
hi-ogawa Dec 9, 2025
4f1340e
refactor: orchestrater trace
hi-ogawa Dec 9, 2025
fd17c84
examples: two files
hi-ogawa Dec 9, 2025
458799a
fix: back to one orchestrator one Traces
hi-ogawa Dec 9, 2025
683b2ba
fix: trace per tester iframe events
hi-ogawa Dec 9, 2025
9480a0c
Merge branch 'main' into 12-04-feat_support_opentelemetry_for_browser…
hi-ogawa Dec 10, 2025
21de3d8
chore: fix browser watch build
hi-ogawa Dec 10, 2025
faa7d4c
cleanup
hi-ogawa Dec 10, 2025
1199e45
docs: @opentelemetry/api is peer dep
hi-ogawa Dec 10, 2025
13336be
todo
hi-ogawa Dec 10, 2025
aaad556
fix: optimize otel deps
hi-ogawa Dec 10, 2025
0cdff75
comment
hi-ogawa Dec 10, 2025
e16cf81
docs: todo
hi-ogawa Dec 10, 2025
5171320
test: add test
hi-ogawa Dec 10, 2025
1322c16
docs
hi-ogawa Dec 10, 2025
bb813bc
cleanup
hi-ogawa Dec 10, 2025
c46b4d1
docs: simplify
hi-ogawa Dec 10, 2025
8d4bc2a
docs: experimental.md
hi-ogawa Dec 10, 2025
52905a4
docs: cleanup
hi-ogawa Dec 10, 2025
f5203f7
refactor tester
hi-ogawa Dec 10, 2025
056b80c
cleanup
hi-ogawa Dec 10, 2025
ae8f376
fix: fix windows
hi-ogawa Dec 10, 2025
6a70d18
examples: test custom trace
hi-ogawa Dec 10, 2025
0bc3a51
fix: please `/@fs/`
hi-ogawa Dec 10, 2025
b6f9bae
trace `vitest.browser.tester.command`
hi-ogawa Dec 11, 2025
a6eee41
cleanup
hi-ogawa Dec 11, 2025
d5899a1
examples: test ZoneContextManager
hi-ogawa Dec 11, 2025
bf13c47
refactor tester
hi-ogawa Dec 11, 2025
7cd9ebf
docs: fix @opentelemetry/exporter-trace-otlp-proto
hi-ogawa Dec 11, 2025
f78898d
fix: endSpan outside of loop
hi-ogawa Dec 11, 2025
9161a57
fix: null check
hi-ogawa Dec 11, 2025
0c41299
fix: move spand.end
hi-ogawa Dec 11, 2025
24f5f2a
Merge branch 'main' into 12-04-feat_support_opentelemetry_for_browser…
hi-ogawa Dec 17, 2025
e2315f1
tweak docs and comment
hi-ogawa Dec 17, 2025
0f7a1d4
Merge branch 'main' into 12-04-feat_support_opentelemetry_for_browser…
hi-ogawa Dec 18, 2025
fa83301
comment
hi-ogawa Dec 18, 2025
a027bfb
add Traces.recordInitSpan
hi-ogawa Dec 18, 2025
758575c
wip: orchestrator recordInitSpan
hi-ogawa Dec 18, 2025
f15572d
fix: move parent trace for orchestrater init span
hi-ogawa Dec 18, 2025
98d3e7a
refactor: pass carrier through orchestrator url
hi-ogawa Dec 18, 2025
7a752ac
refactor: more recordInitSpan
hi-ogawa Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/config/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand All @@ -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:

Expand Down
53 changes: 53 additions & 0 deletions docs/guide/open-telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions examples/opentelemetry/jaeger-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions examples/opentelemetry/otel-browser.js
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions examples/opentelemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions examples/opentelemetry/src/other.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<button>Hello Vitest</button>`
await page.getByRole('button', { name: 'Hello Vitest' }).click()
})
1 change: 1 addition & 0 deletions examples/opentelemetry/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:/'"
},
Expand All @@ -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:",
Expand Down
7 changes: 7 additions & 0 deletions packages/browser/scripts/build-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// wrapper script for `node --watch`.
Comment thread
sheremet-va marked this conversation as resolved.
// this works around some issues with `vite build --watch`.
import { spawn } from 'node:child_process'

spawn('node', ['--run', 'build:client'], {
stdio: 'inherit',
})
2 changes: 2 additions & 0 deletions packages/browser/src/client/channel.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -41,6 +42,7 @@ export interface IframePrepareEvent {
event: 'prepare'
iframeId: string
startTime: number
otelCarrier?: OTELCarrier
}

export type GlobalChannelIncomingEvent = GlobalChannelTestRunCanceledEvent
Expand Down
59 changes: 54 additions & 5 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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),
Expand All @@ -30,6 +40,24 @@ export class IframeOrchestrator {
}

public async createTesters(options: BrowserTesterOptions): Promise<void> {
await this.traces.waitInit()
Comment thread
sheremet-va marked this conversation as resolved.
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()
Comment thread
hi-ogawa marked this conversation as resolved.
}

const startTime = performance.now()

this.cancelled = false
Expand All @@ -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
}

Expand All @@ -58,6 +87,7 @@ export class IframeOrchestrator {

for (let i = 0; i < options.files.length; i++) {
if (this.cancelled) {
await endSpan()
return
}

Expand All @@ -69,8 +99,10 @@ export class IframeOrchestrator {
file,
options,
startTime,
orchestratorSpan.context,
)
}
await endSpan()
}

public async cleanupTesters(): Promise<void> {
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -138,6 +175,7 @@ export class IframeOrchestrator {
spec: FileSpecification,
options: BrowserTesterOptions,
startTime: number,
otelContext: OTELContext,
) {
const config = getConfig()
const { width, height } = config.browser.viewport
Expand All @@ -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({
Expand All @@ -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)

Expand All @@ -194,6 +242,7 @@ export class IframeOrchestrator {
event: 'prepare',
iframeId,
startTime,
otelCarrier: this.traces.getContextCarrier(otelContext),
}).then(resolve, error => reject(this.dispatchIframeError(error)))
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/client/public/esm-client-injector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__ },
Expand Down
Loading
Loading