Skip to content

Commit d6d3359

Browse files
authored
feat(experimental): support OpenTelemetry traces (#8994)
1 parent 259a3d1 commit d6d3359

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2465
-745
lines changed
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
<script setup lang="ts">
22
import { VPBadge } from 'vitepress/theme'
3+
4+
const { type = 'stable' } = defineProps<{
5+
type?: 'stable' | 'experimental'
6+
}>()
37
</script>
48

59
<template>
6-
<VPBadge type="info">
10+
<VPBadge
11+
:type="type === 'experimental' ? 'warning' : 'info'"
12+
:title="type === 'experimental' ? 'This feature is experimental and does not follow SemVer.' : undefined"
13+
>
714
<slot />+
815
</VPBadge>
916
</template>

docs/.vitepress/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,10 @@ export default ({ mode }: { mode: string }) => {
511511
text: 'disableConsoleIntercept',
512512
link: '/config/disableconsoleintercept',
513513
},
514+
{
515+
text: 'experimental',
516+
link: '/config/experimental',
517+
},
514518
],
515519
},
516520
{
@@ -846,6 +850,10 @@ export default ({ mode }: { mode: string }) => {
846850
},
847851
],
848852
},
853+
{
854+
text: 'OpenTelemetry',
855+
link: '/guide/open-telemetry',
856+
},
849857
],
850858
},
851859
{

docs/config/experimental.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
title: experimental | Config
3+
outline: deep
4+
---
5+
6+
# experimental
7+
8+
## openTelemetry <Version type="experimental">4.0.10</Version>
9+
10+
- **Type:**
11+
12+
```ts
13+
interface OpenTelemetryOptions {
14+
enabled: boolean
15+
/**
16+
* A path to a file that exposes an OpenTelemetry SDK.
17+
*/
18+
sdkPath?: string
19+
}
20+
```
21+
22+
- **Default:** `{ enabled: false }`
23+
24+
This option controls [OpenTelemetry](https://opentelemetry.io/) support. Vitest imports the SDK file in the main thread and before every test file, if `enabled` is set to `true`.
25+
26+
::: danger PERFORMANCE CONCERNS
27+
OpenTelemetry may significantly impact Vitest performance; enable it only for local debugging.
28+
:::
29+
30+
You can use a [custom service](/guide/open-telemetry) together with Vitest to pinpoint which tests or files are slowing down your test suite.
31+
32+
::: warning BROWSER SUPPORT
33+
At the moment, Vitest does not start any spans when running in [the browser](/guide/browser/).
34+
:::
35+
36+
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:
37+
38+
::: code-group
39+
```js [otel.js]
40+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
41+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
42+
import { NodeSDK } from '@opentelemetry/sdk-node'
43+
44+
const sdk = new NodeSDK({
45+
serviceName: 'vitest',
46+
traceExporter: new OTLPTraceExporter(),
47+
instrumentations: [getNodeAutoInstrumentations()],
48+
})
49+
50+
sdk.start()
51+
export default sdk
52+
```
53+
```js [vitest.config.js]
54+
import { defineConfig } from 'vitest/config'
55+
56+
export default defineConfig({
57+
test: {
58+
experimental: {
59+
openTelemetry: {
60+
enabled: true,
61+
sdkPath: './otel.js',
62+
},
63+
},
64+
},
65+
})
66+
```
67+
:::
68+
69+
::: warning
70+
It's important that Node can process `sdkPath` content because it is not transformed by Vitest. See [the guide](/guide/open-telemetry) on how to work with OpenTelemetry inside of Vitest.
71+
:::

docs/guide/open-telemetry.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Open Telemetry Support <Experimental /> {#open-telemetry-support}
2+
3+
[OpenTelemetry](https://opentelemetry.io/) traces can be a useful tool to debug the performance and behavior of your application inside tests.
4+
5+
If enabled, Vitest integration generates spans that are scoped to your test's worker.
6+
7+
::: warning
8+
OpenTelemetry initialization increases the startup time of every test unless Vitest runs without [isolation](/config/isolate). You can see it as the `vitest.runtime.traces` span inside `vitest.worker.start`.
9+
:::
10+
11+
To start using OpenTelemetry in Vitest, specify an SDK module path via [`experimental.openTelemetry.sdkPath`](/config/experimental#opentelemetry) and set `experimental.openTelemetry.enabled` to `true`. Vitest will automatically instrument the whole process and each individual test worker.
12+
13+
Make sure to export the SDK as a default export, so that Vitest can flush the network requests before the process is closed. Note that Vitest doesn't automatically call `start`.
14+
15+
## Quickstart
16+
17+
Before previewing your application traces, install required packages and specify the path to your instrumentation file in the config.
18+
19+
```shell
20+
npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-proto
21+
```
22+
23+
::: code-group
24+
```js{12} [otel.js]
25+
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
26+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
27+
import { NodeSDK } from '@opentelemetry/sdk-node'
28+
29+
const sdk = new NodeSDK({
30+
serviceName: 'vitest',
31+
traceExporter: new OTLPTraceExporter(),
32+
instrumentations: [getNodeAutoInstrumentations()],
33+
})
34+
35+
sdk.start()
36+
export default sdk
37+
```
38+
```js [vitest.config.js]
39+
import { defineConfig } from 'vitest/config'
40+
41+
export default defineConfig({
42+
test: {
43+
experimental: {
44+
openTelemetry: {
45+
enabled: true,
46+
sdkPath: './otel.js',
47+
},
48+
},
49+
},
50+
})
51+
```
52+
:::
53+
54+
::: danger FAKE TIMERS
55+
If you are using fake timers, it is important to reset them before the test ends, otherwise traces might not be tracked properly.
56+
:::
57+
58+
Vitest doesn't process the `sdkPath` module, so it is important that the SDK can be imported within your Node.js environment. It is ideal to use the `.js` extension for this file. Using another extension will slow down your tests and may require providing additional Node.js arguments.
59+
60+
If you want to provide a TypeScript file, make sure to familiarize yourself with [TypeScript](https://nodejs.org/api/typescript.html#type-stripping) page in the Node.js documentation.
61+
62+
## Custom Traces
63+
64+
You can use the OpenTelemetry API yourself to track certain operations in your code. Custom traces automatically inherit the Vitest OpenTelemetry context:
65+
66+
```ts
67+
import { trace } from '@opentelemetry/api'
68+
import { test } from 'vitest'
69+
import { db } from './src/db'
70+
71+
const tracer = trace.getTracer('vitest')
72+
73+
test('db connects properly', async () => {
74+
// this is shown inside `vitest.test.runner.test.callback` span
75+
await tracer.startActiveSpan('db.connect', () => db.connect())
76+
})
77+
```
78+
79+
## View Traces
80+
81+
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.
82+
83+
You can view traces using any of the open source or commercial products that support OpenTelemetry API. If you did not use OpenTelemetry before, we recommend starting with [Jaeger](https://www.jaegertracing.io/docs/2.11/getting-started/#all-in-one) because it is really easy to setup.
84+
85+
<img src="/otel-jaeger.png" />
86+
87+
## `@opentelemetry/api`
88+
89+
Vitest declares `@opentelemetry/api` as an optional peer dependency, which it uses internally to generate spans. When trace collection is not enabled, Vitest will not attempt to use this dependency.
90+
91+
When configuring Vitest to use OpenTelemetry, you will typically install `@opentelemetry/sdk-node`, which includes `@opentelemetry/api` as a transitive dependency, thereby satisfying Vitest's peer dependency requirement. If you encounter an error indicating that `@opentelemetry/api` cannot be found, this typically means trace collection has not been enabled. If the error persists after proper configuration, you may need to install `@opentelemetry/api` explicitly.

docs/public/otel-jaeger.png

261 KB
Loading

packages/browser/src/client/tester/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,10 @@ export function createBrowserRunner(
280280
throw new Error(`Failed to import test file ${filepath}`, { cause: err })
281281
}
282282
}
283+
284+
// disable tracing in the browser for now
285+
trace = undefined
286+
__setTraces = undefined
283287
}
284288
}
285289

packages/runner/src/collect.ts

Lines changed: 82 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -26,95 +26,102 @@ export async function collectTests(
2626
const files: File[] = []
2727

2828
const config = runner.config
29+
const $ = runner.trace!
2930

3031
for (const spec of specs) {
3132
const filepath = typeof spec === 'string' ? spec : spec.filepath
32-
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations
33-
34-
const file = createFileTask(filepath, config.root, config.name, runner.pool)
35-
setFileContext(file, Object.create(null))
36-
file.shuffle = config.sequence.shuffle
33+
await $(
34+
'collect_spec',
35+
{ 'code.file.path': filepath },
36+
async () => {
37+
const testLocations = typeof spec === 'string' ? undefined : spec.testLocations
38+
39+
const file = createFileTask(filepath, config.root, config.name, runner.pool)
40+
setFileContext(file, Object.create(null))
41+
file.shuffle = config.sequence.shuffle
42+
43+
runner.onCollectStart?.(file)
44+
45+
clearCollectorContext(filepath, runner)
46+
47+
try {
48+
const setupFiles = toArray(config.setupFiles)
49+
if (setupFiles.length) {
50+
const setupStart = now()
51+
await runSetupFiles(config, setupFiles, runner)
52+
const setupEnd = now()
53+
file.setupDuration = setupEnd - setupStart
54+
}
55+
else {
56+
file.setupDuration = 0
57+
}
3758

38-
runner.onCollectStart?.(file)
59+
const collectStart = now()
3960

40-
clearCollectorContext(filepath, runner)
61+
await runner.importFile(filepath, 'collect')
4162

42-
try {
43-
const setupFiles = toArray(config.setupFiles)
44-
if (setupFiles.length) {
45-
const setupStart = now()
46-
await runSetupFiles(config, setupFiles, runner)
47-
const setupEnd = now()
48-
file.setupDuration = setupEnd - setupStart
49-
}
50-
else {
51-
file.setupDuration = 0
52-
}
63+
const durations = runner.getImportDurations?.()
64+
if (durations) {
65+
file.importDurations = durations
66+
}
5367

54-
const collectStart = now()
68+
const defaultTasks = await getDefaultSuite().collect(file)
69+
70+
const fileHooks = createSuiteHooks()
71+
mergeHooks(fileHooks, getHooks(defaultTasks))
72+
73+
for (const c of [...defaultTasks.tasks, ...collectorContext.tasks]) {
74+
if (c.type === 'test' || c.type === 'suite') {
75+
file.tasks.push(c)
76+
}
77+
else if (c.type === 'collector') {
78+
const suite = await c.collect(file)
79+
if (suite.name || suite.tasks.length) {
80+
mergeHooks(fileHooks, getHooks(suite))
81+
file.tasks.push(suite)
82+
}
83+
}
84+
else {
85+
// check that types are exhausted
86+
c satisfies never
87+
}
88+
}
5589

56-
await runner.importFile(filepath, 'collect')
90+
setHooks(file, fileHooks)
91+
file.collectDuration = now() - collectStart
92+
}
93+
catch (e) {
94+
const error = processError(e)
95+
file.result = {
96+
state: 'fail',
97+
errors: [error],
98+
}
5799

58-
const durations = runner.getImportDurations?.()
59-
if (durations) {
60-
file.importDurations = durations
61-
}
100+
const durations = runner.getImportDurations?.()
101+
if (durations) {
102+
file.importDurations = durations
103+
}
104+
}
62105

63-
const defaultTasks = await getDefaultSuite().collect(file)
106+
calculateSuiteHash(file)
64107

65-
const fileHooks = createSuiteHooks()
66-
mergeHooks(fileHooks, getHooks(defaultTasks))
108+
const hasOnlyTasks = someTasksAreOnly(file)
109+
interpretTaskModes(
110+
file,
111+
config.testNamePattern,
112+
testLocations,
113+
hasOnlyTasks,
114+
false,
115+
config.allowOnly,
116+
)
67117

68-
for (const c of [...defaultTasks.tasks, ...collectorContext.tasks]) {
69-
if (c.type === 'test' || c.type === 'suite') {
70-
file.tasks.push(c)
71-
}
72-
else if (c.type === 'collector') {
73-
const suite = await c.collect(file)
74-
if (suite.name || suite.tasks.length) {
75-
mergeHooks(fileHooks, getHooks(suite))
76-
file.tasks.push(suite)
77-
}
78-
}
79-
else {
80-
// check that types are exhausted
81-
c satisfies never
118+
if (file.mode === 'queued') {
119+
file.mode = 'run'
82120
}
83-
}
84-
85-
setHooks(file, fileHooks)
86-
file.collectDuration = now() - collectStart
87-
}
88-
catch (e) {
89-
const error = processError(e)
90-
file.result = {
91-
state: 'fail',
92-
errors: [error],
93-
}
94-
95-
const durations = runner.getImportDurations?.()
96-
if (durations) {
97-
file.importDurations = durations
98-
}
99-
}
100-
101-
calculateSuiteHash(file)
102-
103-
const hasOnlyTasks = someTasksAreOnly(file)
104-
interpretTaskModes(
105-
file,
106-
config.testNamePattern,
107-
testLocations,
108-
hasOnlyTasks,
109-
false,
110-
config.allowOnly,
111-
)
112-
113-
if (file.mode === 'queued') {
114-
file.mode = 'run'
115-
}
116121

117-
files.push(file)
122+
files.push(file)
123+
},
124+
)
118125
}
119126

120127
return files

0 commit comments

Comments
 (0)