autotel-playwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * autotel-playwright
3
+ *
4
+ * Playwright fixture that creates one OTel span per test and injects W3C trace
5
+ * context into requests to your API so "test → API" appears as one trace.
6
+ *
7
+ * @example
8
+ * // globalSetup.ts: init({ service: 'e2e-tests' });
9
+ * // In spec:
10
+ * import { test, expect } from 'autotel-playwright';
11
+ * test('checks health', async ({ page }) => {
12
+ * await page.goto(API_BASE_URL + '/health'); // request gets traceparent
13
+ * });
14
+ * // Node-side API calls with trace context:
15
+ * test('api health', async ({ requestWithTrace }) => {
16
+ * const res = await requestWithTrace.get(API_BASE_URL + '/health');
17
+ * expect(res.ok()).toBeTruthy();
18
+ * });
19
+ */
20
+
21
+ import { test as base } from '@playwright/test';
22
+ import type { Page, APIRequestContext, Request as PlaywrightRequest } from '@playwright/test';
23
+ import type { TestInfo } from '@playwright/test';
24
+ import type { AutotelConfig } from 'autotel';
25
+ import {
26
+ getTracer,
27
+ getAutotelTracerProvider,
28
+ context as otelContext,
29
+ propagation,
30
+ otelTrace,
31
+ SpanStatusCode,
32
+ } from 'autotel';
33
+ import { TestSpanCollector } from 'autotel/test-span-collector';
34
+ import { SimpleSpanProcessor } from 'autotel/processors';
35
+
36
+ const TRACER_NAME = 'playwright-tests';
37
+ const TRACER_VERSION = '0.1.0';
38
+
39
+ let collector: TestSpanCollector | null = null;
40
+
41
+ function ensureCollector(): TestSpanCollector {
42
+ if (!collector) {
43
+ collector = new TestSpanCollector();
44
+ const provider = getAutotelTracerProvider();
45
+ if ('addSpanProcessor' in provider) {
46
+ (provider as any).addSpanProcessor(new SimpleSpanProcessor(collector));
47
+ }
48
+ }
49
+ return collector;
50
+ }
51
+
52
+ /** Env keys for API base URL (requests to this origin get trace context injected). */
53
+ const ENV_API_BASE_URL = 'API_BASE_URL';
54
+ const ENV_API_ORIGIN = 'AUTOTEL_PLAYWRIGHT_API_ORIGIN';
55
+
56
+ function getApiBaseUrls(): string[] {
57
+ const a = process.env[ENV_API_BASE_URL];
58
+ const b = process.env[ENV_API_ORIGIN];
59
+ const urls: string[] = [];
60
+ if (a) urls.push(a.replace(/\/$/, ''));
61
+ if (b) urls.push(b.replace(/\/$/, ''));
62
+ return [...new Set(urls)];
63
+ }
64
+
65
+ /**
66
+ * Returns true if requestUrl should receive trace headers for the given apiBaseUrls.
67
+ * When a base URL includes a path (e.g. http://localhost:3000/api), only requests
68
+ * whose path starts with that path segment match; same-origin but different path
69
+ * (e.g. /health) must not match to avoid leaking trace context to unrelated endpoints.
70
+ */
71
+ function urlMatchesApiOrigin(requestUrl: string, apiBaseUrls: string[]): boolean {
72
+ if (apiBaseUrls.length === 0) return false;
73
+ try {
74
+ const u = new URL(requestUrl);
75
+ const requestOrigin = u.origin;
76
+ const requestPathname = u.pathname;
77
+ return apiBaseUrls.some((base) => {
78
+ try {
79
+ const b = new URL(base);
80
+ if (requestOrigin !== b.origin) return false;
81
+ const basePathname = b.pathname.replace(/\/$/, '') || '/';
82
+ if (basePathname === '/') return true;
83
+ return (
84
+ requestPathname === basePathname || requestPathname.startsWith(basePathname + '/')
85
+ );
86
+ } catch {
87
+ return requestUrl.startsWith(base);
88
+ }
89
+ });
90
+ } catch {
91
+ return apiBaseUrls.some((base) => requestUrl.startsWith(base));
92
+ }
93
+ }
94
+
95
+ /** Annotation type for custom span attributes: description should be "key=value" or "key=value1;key2=value2". */
96
+ export const AUTOTEL_ATTRIBUTE_ANNOTATION = 'autotel.attribute';
97
+
98
+ function setAttributesFromAnnotations(
99
+ span: { setAttribute: (k: string, v: string | number | boolean) => void },
100
+ testInfo: { annotations: Array<{ type: string; description?: string }> },
101
+ ): void {
102
+ for (const a of testInfo.annotations) {
103
+ if (a.type !== AUTOTEL_ATTRIBUTE_ANNOTATION || !a.description) continue;
104
+ const entries = a.description.split(';');
105
+ for (const entry of entries) {
106
+ const parts = entry.split('=');
107
+ if (parts.length >= 2) {
108
+ const key = parts[0].trim();
109
+ const value = parts.slice(1).join('=').trim();
110
+ span.setAttribute(key, value);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ /** Internal: options for get/post/put/patch/delete/head/fetch that may include headers. */
117
+ type RequestOptions = Record<string, unknown> & { headers?: Record<string, string> };
118
+
119
+ function mergeTraceHeaders(
120
+ url: string,
121
+ options: RequestOptions | undefined,
122
+ apiBaseUrls: string[],
123
+ carrier: Record<string, string>,
124
+ testName: string,
125
+ ): RequestOptions {
126
+ const opts = options ?? {};
127
+ if (!urlMatchesApiOrigin(url, apiBaseUrls)) return opts;
128
+ return {
129
+ ...opts,
130
+ headers: { ...(opts.headers as Record<string, string>), ...carrier, 'x-test-name': testName },
131
+ };
132
+ }
133
+
134
+ /** Wraps APIRequestContext so requests to API_BASE_URL get trace context injected. */
135
+ function createRequestWithTrace(
136
+ request: APIRequestContext,
137
+ apiBaseUrls: string[],
138
+ carrier: Record<string, string>,
139
+ testInfo: TestInfo,
140
+ ): APIRequestContext {
141
+ const merge = (url: string, options?: RequestOptions) =>
142
+ mergeTraceHeaders(url, options, apiBaseUrls, carrier, testInfo.title);
143
+
144
+ return {
145
+ get: (url: string, options?: RequestOptions) => request.get(url, merge(url, options)),
146
+ post: (url: string, options?: RequestOptions) => request.post(url, merge(url, options)),
147
+ put: (url: string, options?: RequestOptions) => request.put(url, merge(url, options)),
148
+ patch: (url: string, options?: RequestOptions) => request.patch(url, merge(url, options)),
149
+ delete: (url: string, options?: RequestOptions) => request.delete(url, merge(url, options)),
150
+ head: (url: string, options?: RequestOptions) => request.head(url, merge(url, options)),
151
+ fetch: (urlOrRequest: string | PlaywrightRequest, options?: RequestOptions) =>
152
+ request.fetch(urlOrRequest, merge(typeof urlOrRequest === 'string' ? urlOrRequest : urlOrRequest.url(), options)),
153
+ storageState: (options?: { path?: string }) => request.storageState(options),
154
+ dispose: () => request.dispose(),
155
+ } as APIRequestContext;
156
+ }
157
+
158
+ type OtelTestSpan = {
159
+ carrier: Record<string, string>;
160
+ apiBaseUrls: string[];
161
+ testInfo: TestInfo;
162
+ };
163
+
164
+ export const test = base.extend<{
165
+ page: Page;
166
+ requestWithTrace: APIRequestContext;
167
+ _otelTestSpan: OtelTestSpan;
168
+ }>({
169
+ _otelTestSpan: [
170
+ // eslint-disable-next-line no-empty-pattern
171
+ async ({}, use, testInfo) => {
172
+ ensureCollector();
173
+ const apiBaseUrls = getApiBaseUrls();
174
+ const tracer = getTracer(TRACER_NAME, TRACER_VERSION);
175
+ const spanName = `e2e:${testInfo.title}`;
176
+ const span = tracer.startSpan(spanName, {
177
+ attributes: {
178
+ 'test.title': testInfo.title,
179
+ 'test.project': testInfo.project.name,
180
+ 'test.file': testInfo.file ?? '',
181
+ 'test.line': testInfo.line ?? 0,
182
+ },
183
+ });
184
+ setAttributesFromAnnotations(span, testInfo);
185
+ const ctx = otelTrace.setSpan(otelContext.active(), span);
186
+ const carrier: Record<string, string> = {};
187
+ propagation.inject(ctx, carrier);
188
+ try {
189
+ await use({ carrier, apiBaseUrls, testInfo });
190
+ } finally {
191
+ span.end();
192
+ const traceId = span.spanContext().traceId;
193
+ const rootSpanId = span.spanContext().spanId;
194
+ const spans = collector!.drainTrace(traceId, rootSpanId);
195
+ if (spans.length > 0) {
196
+ testInfo.annotations.push({
197
+ type: 'otel-spans',
198
+ description: JSON.stringify(spans),
199
+ });
200
+ }
201
+ }
202
+ },
203
+ { scope: 'test' },
204
+ ],
205
+
206
+ page: async ({ page, _otelTestSpan }, use) => {
207
+ const { carrier, apiBaseUrls, testInfo } = _otelTestSpan;
208
+ if (apiBaseUrls.length > 0) {
209
+ await page.route('**/*', async (route) => {
210
+ const request = route.request();
211
+ const url = request.url();
212
+ if (urlMatchesApiOrigin(url, apiBaseUrls)) {
213
+ const headers = {
214
+ ...request.headers(),
215
+ ...carrier,
216
+ 'x-test-name': testInfo.title,
217
+ };
218
+ await route.continue({ headers });
219
+ } else {
220
+ await route.continue();
221
+ }
222
+ });
223
+ }
224
+ await use(page);
225
+ },
226
+
227
+ requestWithTrace: async ({ request, _otelTestSpan }, use) => {
228
+ const wrapped = createRequestWithTrace(
229
+ request,
230
+ _otelTestSpan.apiBaseUrls,
231
+ _otelTestSpan.carrier,
232
+ _otelTestSpan.testInfo,
233
+ );
234
+ await use(wrapped);
235
+ },
236
+ });
237
+
238
+ export { expect } from '@playwright/test';
239
+
240
+ // Re-export trace context helpers for DX convenience
241
+ export {
242
+ getTraceContext,
243
+ resolveTraceUrl,
244
+ isTracing,
245
+ enrichWithTraceContext,
246
+ } from 'autotel';
247
+
248
+ export type { OtelTraceContext } from 'autotel';
249
+
250
+ /**
251
+ * Runs a named step as a child span of the current test span. Use inside a test to get
252
+ * step-level spans (e.g. "step:login", "step:navigate") under the test span in the same trace.
253
+ *
254
+ * @example
255
+ * test('user flow', async ({ page }) => {
256
+ * await step('login', async () => {
257
+ * await page.click('button[type=submit]');
258
+ * });
259
+ * await step('open profile', async () => {
260
+ * await page.goto('/profile');
261
+ * });
262
+ * });
263
+ */
264
+ export async function step<T>(name: string, fn: () => Promise<T>): Promise<T> {
265
+ const tracer = getTracer(TRACER_NAME, TRACER_VERSION);
266
+ const span = tracer.startSpan(`step:${name}`, {
267
+ attributes: { 'step.name': name },
268
+ });
269
+ try {
270
+ return await otelContext.with(otelTrace.setSpan(otelContext.active(), span), fn);
271
+ } catch (error) {
272
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : 'Unknown error' });
273
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
274
+ throw error;
275
+ } finally {
276
+ span.end();
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Returns a function suitable for Playwright globalSetup that inits autotel.
282
+ * Call autotel.init() with the given options (or defaults) so test spans are exported.
283
+ */
284
+ export function createGlobalSetup(initOptions?: AutotelConfig): () => Promise<void> {
285
+ return async () => {
286
+ const { init } = await import('autotel');
287
+ init({
288
+ service: 'e2e-tests',
289
+ debug: true,
290
+ ...initOptions,
291
+ });
292
+ };
293
+ }
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest';
2
+
3
+ const spans: Array<{ end: ReturnType<typeof vi.fn> }> = [];
4
+
5
+ vi.mock('autotel', () => ({
6
+ SpanStatusCode: { ERROR: 2 },
7
+ context: {
8
+ active: () => ({}),
9
+ with: (_ctx: unknown, fn: () => void) => fn(),
10
+ },
11
+ getTracer: () => ({
12
+ startSpan: () => {
13
+ const span = {
14
+ end: vi.fn(),
15
+ recordException: vi.fn(),
16
+ setStatus: vi.fn(),
17
+ };
18
+ spans.push(span);
19
+ return span;
20
+ },
21
+ }),
22
+ otelTrace: {
23
+ setSpan: () => ({}),
24
+ },
25
+ }));
26
+
27
+ describe('OtelReporter', () => {
28
+ afterEach(() => {
29
+ spans.length = 0;
30
+ vi.resetModules();
31
+ });
32
+
33
+ it('tracks same file/line/title tests independently (no key collisions)', async () => {
34
+ const { OtelReporter } = await import('./reporter');
35
+ const reporter = new OtelReporter();
36
+
37
+ const testA = {
38
+ id: 'project-a-id',
39
+ title: 'shared title',
40
+ location: { file: 'e2e/spec.ts', line: 7 },
41
+ } as any;
42
+ const testB = {
43
+ id: 'project-b-id',
44
+ title: 'shared title',
45
+ location: { file: 'e2e/spec.ts', line: 7 },
46
+ } as any;
47
+
48
+ reporter.onTestBegin(testA, {} as any);
49
+ reporter.onTestBegin(testB, {} as any);
50
+
51
+ reporter.onTestEnd(testA, { status: 'passed' } as any);
52
+
53
+ expect(spans).toHaveLength(2);
54
+ expect(spans[0].end).toHaveBeenCalledTimes(1);
55
+ expect(spans[1].end).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it('marks a step span as error when step.error exists even if result.status is passed', async () => {
59
+ const { OtelReporter } = await import('./reporter');
60
+ const reporter = new OtelReporter();
61
+
62
+ const test = {
63
+ id: 'project-a-id',
64
+ title: 'step failure case',
65
+ location: { file: 'e2e/spec.ts', line: 12 },
66
+ } as any;
67
+
68
+ const step = {
69
+ title: 'failing step',
70
+ error: { message: 'boom', stack: 'stack' },
71
+ } as any;
72
+
73
+ reporter.onTestBegin(test, {} as any);
74
+ reporter.onStepBegin(test, {} as any, step);
75
+ reporter.onStepEnd(test, { status: 'passed' } as any, step);
76
+
77
+ const stepSpan = spans[1] as any;
78
+ expect(stepSpan.recordException).toHaveBeenCalled();
79
+ expect(stepSpan.setStatus).toHaveBeenCalledWith({ code: 2 });
80
+ });
81
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Optional Playwright reporter that creates OTel spans for each test and step.
3
+ * Runs in the runner process; ensure autotel.init() is called in globalSetup so spans are exported.
4
+ *
5
+ * Use when you want test/step timing and hierarchy in OTLP from the runner side.
6
+ * For "test → API" in one trace (worker side), use the test fixture and requestWithTrace.
7
+ *
8
+ * @example
9
+ * // playwright.config.ts
10
+ * import { defineConfig } from '@playwright/test';
11
+ *
12
+ * export default defineConfig({
13
+ * reporter: [['list'], ['autotel-playwright/reporter']],
14
+ * globalSetup: './globalSetup.ts', // must call init()
15
+ * });
16
+ */
17
+
18
+ import type {
19
+ FullConfig,
20
+ FullResult,
21
+ Reporter,
22
+ Suite,
23
+ TestCase,
24
+ TestResult,
25
+ TestStep,
26
+ } from '@playwright/test/reporter';
27
+ import { getTracer, context as otelContext, otelTrace, SpanStatusCode } from 'autotel';
28
+
29
+ const TRACER_NAME = 'playwright-reporter';
30
+ const TRACER_VERSION = '0.1.0';
31
+
32
+ function testKey(test: TestCase): string {
33
+ return test.id;
34
+ }
35
+
36
+ /** Convert Playwright TestError (no `name` field) to a standard Error for OTel. */
37
+ function toError(testError: { message?: string; stack?: string }): Error {
38
+ const err = new Error(testError.message ?? 'Unknown error');
39
+ if (testError.stack) err.stack = testError.stack;
40
+ return err;
41
+ }
42
+
43
+ /**
44
+ * Playwright Reporter that creates one span per test and one per step (as children).
45
+ * Requires autotel.init() in globalSetup so spans are exported.
46
+ */
47
+ class OtelReporter implements Reporter {
48
+ private testSpans = new Map<string, ReturnType<ReturnType<typeof getTracer>['startSpan']>>();
49
+ private stepSpans = new WeakMap<TestStep, ReturnType<ReturnType<typeof getTracer>['startSpan']>>();
50
+
51
+ onTestBegin(test: TestCase, _result: TestResult): void {
52
+ const tracer = getTracer(TRACER_NAME, TRACER_VERSION);
53
+ const span = tracer.startSpan(`e2e:${test.title}`, {
54
+ attributes: {
55
+ 'test.title': test.title,
56
+ 'test.file': test.location?.file ?? '',
57
+ 'test.line': test.location?.line ?? 0,
58
+ },
59
+ });
60
+ this.testSpans.set(testKey(test), span);
61
+ }
62
+
63
+ onTestEnd(test: TestCase, result: TestResult): void {
64
+ const key = testKey(test);
65
+ const span = this.testSpans.get(key);
66
+ if (span) {
67
+ if (result.status !== 'passed' && result.status !== 'skipped') {
68
+ span.setStatus({ code: SpanStatusCode.ERROR });
69
+ if (result.error) span.recordException(toError(result.error));
70
+ }
71
+ span.end();
72
+ this.testSpans.delete(key);
73
+ }
74
+ }
75
+
76
+ onStepBegin(test: TestCase, _result: TestResult, step: TestStep): void {
77
+ const testSpan = this.testSpans.get(testKey(test));
78
+ if (!testSpan) return;
79
+ otelContext.with(otelTrace.setSpan(otelContext.active(), testSpan), () => {
80
+ const tracer = getTracer(TRACER_NAME, TRACER_VERSION);
81
+ const span = tracer.startSpan(`step:${step.title}`, {
82
+ attributes: { 'step.name': step.title },
83
+ });
84
+ this.stepSpans.set(step, span);
85
+ });
86
+ }
87
+
88
+ onStepEnd(_test: TestCase, result: TestResult, step: TestStep): void {
89
+ const span = this.stepSpans.get(step);
90
+ if (span) {
91
+ if (step.error) {
92
+ span.recordException(toError(step.error));
93
+ span.setStatus({ code: SpanStatusCode.ERROR });
94
+ }
95
+ span.end();
96
+ this.stepSpans.delete(step);
97
+ }
98
+ }
99
+
100
+ onBegin?(_config: FullConfig, _suite: Suite): void {}
101
+ onEnd?(_result: FullResult): void {}
102
+ }
103
+
104
+ export { OtelReporter };
105
+ export default OtelReporter;