diff --git a/scenarios/http/js/README.md b/scenarios/http/js/README.md index d4c93cbad..99410b9b4 100644 --- a/scenarios/http/js/README.md +++ b/scenarios/http/js/README.md @@ -7,6 +7,8 @@ other language. ```text express/scenarios/ what the server does, no OTel express/opentelemetry-express/ server.js, server/ +fetch/scenarios/ what the browser Fetch client does, no OTel +fetch/opentelemetry-fetch/ browser.js, client/ http/scenarios/ built-in client and server, no OTel http/opentelemetry-http/ client.js, server.js, client/, server/ undici/scenarios/ what the client does, no OTel @@ -56,6 +58,24 @@ undici does not go through Node's `http` module, so its client spans come from `undici.request`, which reports every status rather than throwing on 4xx and 5xx: the contract's failing statuses are traffic to be measured like any other. +## Fetch + +Fetch is a browser API, so its scenario opens a headless browser through the +shared browser runner. The runner serves the bundled browser workload, reserves +only its own exporter and script endpoints, and proxies every contract request +to the mock server unchanged. Browser spans therefore retain the contract's +exact path and query string. Its telemetry bridge accepts the browser's +OTLP/HTTP protobuf exports and forwards them to the runner's OTLP/gRPC +collector; the exporter endpoint is ignored by Fetch instrumentation. + +The browser runner first launches an installed Google Chrome and falls back to +Playwright-managed Chromium. Install that fallback once in the build root when +Chrome is unavailable: + +```sh +npm exec playwright install chromium +``` + ## Running one Use Node 22.19.0 or newer. The lockfile pins `undici@8.10.0`, which requires @@ -65,6 +85,7 @@ that version. pip install -e tools/runner -e tools/http/runner -e tools/http/mock-server \ -e tools/http/test-client/python -e tools/js otel-conformance scenarios/http/js/express/opentelemetry-express/server +otel-conformance scenarios/http/js/fetch/opentelemetry-fetch/client otel-conformance scenarios/http/js/http/opentelemetry-http/client otel-conformance scenarios/http/js/http/opentelemetry-http/server otel-conformance scenarios/http/js/undici/opentelemetry-undici/client diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/browser.js b/scenarios/http/js/fetch/opentelemetry-fetch/browser.js new file mode 100644 index 000000000..45ff1a42e --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/browser.js @@ -0,0 +1,87 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const { + OTLPTraceExporter, +} = require("@opentelemetry/exporter-trace-otlp-proto"); +const { registerInstrumentations } = require("@opentelemetry/instrumentation"); +const { + FetchInstrumentation, +} = require("@opentelemetry/instrumentation-fetch"); +const { + SimpleSpanProcessor, + WebTracerProvider, +} = require("@opentelemetry/sdk-trace-web"); +const { drive } = require("@otel-conformance/fetch-scenarios"); + +/** Resolves once exactly `expected` Fetch spans have reached the exporter. */ +function trackingExporter(exporter, expected) { + let exported = 0; + let resolve; + let reject; + const complete = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { + export(spans, callback) { + exporter.export(spans, (result) => { + callback(result); + if (result.code !== 0) { + reject(result.error || new Error("OTLP export failed")); + return; + } + exported += spans.length; + if (exported === expected) { + resolve(); + } else if (exported > expected) { + reject(new Error(`exported ${exported} spans, expected ${expected}`)); + } + }); + }, + forceFlush() { + return exporter.forceFlush(); + }, + shutdown() { + return exporter.shutdown(); + }, + waitForExpectedSpans() { + return complete; + }, + }; +} + +async function run() { + const { baseUrl, exchanges, traceExporterUrl } = window.__otelConformance; + const exporter = trackingExporter( + new OTLPTraceExporter({ url: traceExporterUrl }), + exchanges.filter((exchange) => !exchange.readiness).length, + ); + const provider = new WebTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + provider.register(); + registerInstrumentations({ + tracerProvider: provider, + instrumentations: [ + new FetchInstrumentation({ ignoreUrls: [traceExporterUrl] }), + ], + }); + await drive(baseUrl, exchanges); + await exporter.waitForExpectedSpans(); + await provider.forceFlush(); +} + +run().then( + () => { + window.__otelConformanceResult = { ok: true }; + }, + (error) => { + window.__otelConformanceResult = { + ok: false, + error: String(error.stack || error), + }; + }, +); diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/client.js b/scenarios/http/js/fetch/opentelemetry-fetch/client.js new file mode 100644 index 000000000..a9a34a3d5 --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/client.js @@ -0,0 +1,17 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const path = require("node:path"); + +const { runBrowserScenario } = require("@otel-conformance/browser-runner"); +const { scenarioRequest } = require("@otel-conformance/http-test-client"); + +runBrowserScenario({ + entrypoint: path.join(__dirname, "browser.js"), + exchanges: [scenarioRequest()], +}).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/client/conformance.yaml b/scenarios/http/js/fetch/opentelemetry-fetch/client/conformance.yaml new file mode 100644 index 000000000..46debdc3c --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/client/conformance.yaml @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +runner: http-conformance +instrumented_library: fetch +instrumentation_library: "@opentelemetry/instrumentation-fetch" + +setup: otel-conformance-js install + +server: + run: http-mock-server --port ${PORT} + +scenarios: + client: + run: node ../client.js diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/client/data.json b/scenarios/http/js/fetch/opentelemetry-fetch/client/data.json new file mode 100644 index 000000000..a3a987ad3 --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/client/data.json @@ -0,0 +1,53 @@ +{ + "spans": { + "http.client": [ + "error.type", + "http.request.method", + "http.response.status_code", + "server.address", + "server.port", + "url.full" + ] + }, + "events": {}, + "metrics": {}, + "entities": { + "service": { + "identity": [ + "service.name" + ], + "description": [] + }, + "telemetry.sdk": { + "identity": [ + "telemetry.sdk.language", + "telemetry.sdk.name" + ], + "description": [ + "telemetry.sdk.version" + ] + } + }, + "findings": [ + { + "id": "recommended_attribute_not_present", + "message": "Span 'GET' is missing recommended attribute 'network.protocol.version'", + "signal_type": "span", + "signal_name": "GET", + "context": { + "attribute_key": "network.protocol.version", + "kind": "client" + } + }, + { + "id": "recommended_attribute_not_present", + "message": "Span 'POST' is missing recommended attribute 'network.protocol.version'", + "signal_type": "span", + "signal_name": "POST", + "context": { + "attribute_key": "network.protocol.version", + "kind": "client" + } + } + ] +} diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/package.json b/scenarios/http/js/fetch/opentelemetry-fetch/package.json new file mode 100644 index 000000000..2357ebf40 --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/package.json @@ -0,0 +1,16 @@ +{ + "name": "@otel-conformance/fetch-opentelemetry-fetch", + "version": "0.1.0", + "private": true, + "description": "Browser Fetch measured through @opentelemetry/instrumentation-fetch.", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/exporter-trace-otlp-proto": "0.221.0", + "@opentelemetry/instrumentation": "0.221.0", + "@opentelemetry/instrumentation-fetch": "0.221.0", + "@opentelemetry/sdk-trace-web": "2.10.0", + "@otel-conformance/browser-runner": "file:../../../../../tools/js/browser-runner", + "@otel-conformance/fetch-scenarios": "*", + "@otel-conformance/http-test-client": "file:../../../../../tools/http/test-client/js" + } +} diff --git a/scenarios/http/js/fetch/opentelemetry-fetch/test/client.test.js b/scenarios/http/js/fetch/opentelemetry-fetch/test/client.test.js new file mode 100644 index 000000000..06a1460f5 --- /dev/null +++ b/scenarios/http/js/fetch/opentelemetry-fetch/test/client.test.js @@ -0,0 +1,219 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const http = require("node:http"); +const path = require("node:path"); +const test = require("node:test"); +const { URL } = require("node:url"); + +const grpc = require("@grpc/grpc-js"); +const { respond } = require("@otel-conformance/http-test-client"); + +const TRACE_EXPORT_PATH = + "/opentelemetry.proto.collector.trace.v1.TraceService/Export"; + +const service = { + export: { + path: TRACE_EXPORT_PATH, + requestStream: false, + responseStream: false, + requestSerialize: (value) => value, + requestDeserialize: (value) => value, + responseSerialize: (value) => value, + responseDeserialize: (value) => value, + }, +}; + +function listen(server) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function close(server) { + server.closeAllConnections?.(); + return new Promise((resolve) => server.close(resolve)); +} + +function bind(server) { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => (error ? reject(error) : resolve(port)), + ); + }); +} + +function run(environment) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["client.js"], { + cwd: path.resolve(__dirname, ".."), + env: { ...process.env, ...environment }, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`scenario exited ${code}`)), + ); + }); +} + +function varint(buffer, offset) { + let value = 0; + let shift = 0; + for (;;) { + const byte = buffer[offset++]; + value += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) { + return { offset, value }; + } + shift += 7; + } +} + +function fields(buffer) { + const result = []; + let offset = 0; + while (offset < buffer.length) { + const key = varint(buffer, offset); + offset = key.offset; + const wireType = key.value & 7; + const number = key.value >> 3; + if (wireType === 0) { + const value = varint(buffer, offset); + result.push({ number, value: value.value, wireType }); + offset = value.offset; + continue; + } + if (wireType === 2) { + const size = varint(buffer, offset); + offset = size.offset; + result.push({ + number, + value: buffer.subarray(offset, offset + size.value), + wireType, + }); + offset += size.value; + continue; + } + if (wireType === 1 || wireType === 5) { + const size = wireType === 1 ? 8 : 4; + result.push({ + number, + value: buffer.subarray(offset, offset + size), + wireType, + }); + offset += size; + continue; + } + throw new Error(`unsupported protobuf wire type ${wireType}`); + } + return result; +} + +function first(fieldsList, number, wireType) { + return fieldsList.find( + (field) => field.number === number && field.wireType === wireType, + )?.value; +} + +function all(fieldsList, number, wireType) { + return fieldsList + .filter((field) => field.number === number && field.wireType === wireType) + .map((field) => field.value); +} + +function text(value) { + return value.toString("utf8"); +} + +function attribute(value) { + const keyValue = fields(value); + const key = text(first(keyValue, 1, 2)); + const anyValue = fields(first(keyValue, 2, 2)); + const raw = first(anyValue, 1, 2) ?? first(anyValue, 3, 0); + return [key, Buffer.isBuffer(raw) ? text(raw) : raw]; +} + +function decodeSpans(payload) { + return all(fields(payload), 1, 2).flatMap((resourceSpans) => + all(fields(resourceSpans), 2, 2).flatMap((scopeSpans) => + all(fields(scopeSpans), 2, 2).map((encoded) => { + const span = fields(encoded); + return { + attributes: new Map(all(span, 9, 2).map(attribute)), + kind: first(span, 6, 0), + name: text(first(span, 5, 2)), + }; + }), + ), + ); +} + +test("measures only the selected browser Fetch request", async () => { + const mock = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + const body = chunks.length ? Buffer.concat(chunks).toString("utf8") : null; + const answer = respond( + request.method, + new URL(request.url, "http://unused").pathname, + body, + ); + response.writeHead(answer.status, { + "content-type": "application/json", + }); + response.end(answer.body); + }); + await listen(mock); + + const exports = []; + const collector = new grpc.Server(); + collector.addService(service, { + export(call, callback) { + exports.push(call.request); + callback(null, Buffer.alloc(0)); + }, + }); + const collectorPort = await bind(collector); + collector.start(); + + try { + await run({ + MOCK_SERVER_URL: `http://127.0.0.1:${mock.address().port}`, + OTEL_CONFORMANCE_SCENARIO_INDEX: "2", + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${collectorPort}`, + }); + } finally { + await close(mock); + collector.forceShutdown(); + } + const spans = exports.flatMap(decodeSpans); + assert.equal(spans.length, 1, "the exporter request must not create a span"); + assert.deepEqual( + spans.map(({ attributes, kind, name }) => { + const url = new URL(attributes.get("url.full")); + return { + kind, + method: attributes.get("http.request.method"), + name, + path: `${url.pathname}${url.search}`, + status: attributes.get("http.response.status_code"), + }; + }), + [ + { + kind: 3, + method: "POST", + name: "POST", + path: "/items", + status: 201, + }, + ], + ); +}); diff --git a/scenarios/http/js/fetch/scenarios/client.js b/scenarios/http/js/fetch/scenarios/client.js new file mode 100644 index 000000000..57c749b87 --- /dev/null +++ b/scenarios/http/js/fetch/scenarios/client.js @@ -0,0 +1,41 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const { + isJsonEqual, +} = require("@otel-conformance/http-test-client/json-equal"); + +/** Sends the shared exchanges with browser Fetch, without telemetry setup. */ + +async function drive(baseUrl, exchanges) { + for (const exchange of exchanges) { + if (exchange.readiness) { + continue; + } + const response = await fetch(`${baseUrl}${exchange.path}`, { + method: exchange.method, + headers: + exchange.body === null ? {} : { "content-type": "application/json" }, + body: exchange.body, + }); + const body = await response.text(); + if (response.status !== exchange.status) { + throw new Error( + `${exchange.method} ${exchange.path} answered ${response.status}, expected ${exchange.status}`, + ); + } + const expected = exchange.responseBody.replace( + "${requestBody}", + () => exchange.body ?? "{}", + ); + if (!isJsonEqual(JSON.parse(body), JSON.parse(expected))) { + throw new Error( + `${exchange.method} ${exchange.path} answered outside the contract`, + ); + } + } +} + +module.exports = { drive }; diff --git a/scenarios/http/js/fetch/scenarios/package.json b/scenarios/http/js/fetch/scenarios/package.json new file mode 100644 index 000000000..4936fb604 --- /dev/null +++ b/scenarios/http/js/fetch/scenarios/package.json @@ -0,0 +1,11 @@ +{ + "name": "@otel-conformance/fetch-scenarios", + "version": "0.1.0", + "private": true, + "description": "What the browser Fetch HTTP conformance scenario does, with no OpenTelemetry in it.", + "license": "Apache-2.0", + "main": "client.js", + "dependencies": { + "@otel-conformance/http-test-client": "0.1.0" + } +} diff --git a/scenarios/http/js/package-lock.json b/scenarios/http/js/package-lock.json index 8c93fb3a5..02dd97199 100644 --- a/scenarios/http/js/package-lock.json +++ b/scenarios/http/js/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "workspaces": [ "express/*", + "fetch/*", "http/*", "undici/*" ], @@ -38,6 +39,192 @@ "express": "5.3.0" } }, + "fetch/opentelemetry-fetch": { + "name": "@otel-conformance/fetch-opentelemetry-fetch", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/exporter-trace-otlp-proto": "0.221.0", + "@opentelemetry/instrumentation": "0.221.0", + "@opentelemetry/instrumentation-fetch": "0.221.0", + "@opentelemetry/sdk-trace-web": "2.10.0", + "@otel-conformance/browser-runner": "file:../../../../../tools/js/browser-runner", + "@otel-conformance/fetch-scenarios": "*", + "@otel-conformance/http-test-client": "file:../../../../../tools/http/test-client/js" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/api-logs": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.221.0.tgz", + "integrity": "sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", + "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.221.0.tgz", + "integrity": "sha512-Z9i2T7vgZbWe9rSLYxXVIbeW+XyzUq4rZanW3ZyVNwVDqCsh0EJKUgBWWQ0CZfeuUA+RQPzKgJQHMuWAUnKqXw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/otlp-exporter-base": "0.221.0", + "@opentelemetry/otlp-transformer": "0.221.0", + "@opentelemetry/sdk-trace": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/instrumentation": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.221.0.tgz", + "integrity": "sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.221.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.221.0.tgz", + "integrity": "sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/otlp-transformer": "0.221.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/otlp-transformer": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.221.0.tgz", + "integrity": "sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.221.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-logs": "0.221.0", + "@opentelemetry/sdk-metrics": "2.10.0", + "@opentelemetry/sdk-trace": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/resources": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz", + "integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/sdk-logs": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.221.0.tgz", + "integrity": "sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.221.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/sdk-metrics": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.10.0.tgz", + "integrity": "sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "fetch/opentelemetry-fetch/node_modules/@opentelemetry/sdk-trace": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz", + "integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "fetch/scenarios": { + "name": "@otel-conformance/fetch-scenarios", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@otel-conformance/http-test-client": "0.1.0" + } + }, "http/opentelemetry-http": { "name": "@otel-conformance/node-http-opentelemetry-http", "version": "0.1.0", @@ -57,6 +244,422 @@ "@otel-conformance/scenario-support": "file:../../../../../tools/js/scenario-support" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.5", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", @@ -389,6 +992,24 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.221.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.221.0.tgz", + "integrity": "sha512-PPfEC0dIvhieidxtajOsbgFZ2RBUhCsk1ztHE6I5uinNc4JocL+UNRQY6iCBsckI0rjTwYvsYRYOJUjGyLhIug==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/instrumentation": "0.221.0", + "@opentelemetry/sdk-trace-web": "2.10.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, "node_modules/@opentelemetry/instrumentation-http": { "version": "0.223.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.223.0.tgz", @@ -651,6 +1272,22 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.10.0.tgz", + "integrity": "sha512-6WqXdWSlBH0GNjDhv1gg6SLVtMtIBVhLaWqd1hiKSXS8meXXP13sVPQoYFHz1uVo0feUTUCdP9vMS54KflisAw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.10.0", + "@opentelemetry/sdk-trace-base": "2.10.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", @@ -660,6 +1297,16 @@ "node": ">=14" } }, + "node_modules/@otel-conformance/browser-runner": { + "version": "0.1.0", + "resolved": "file:../../../tools/js/browser-runner", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "1.14.4", + "@playwright/test": "1.62.1", + "esbuild": "0.28.2" + } + }, "node_modules/@otel-conformance/express-opentelemetry-express": { "resolved": "express/opentelemetry-express", "link": true @@ -668,6 +1315,14 @@ "resolved": "express/scenarios", "link": true }, + "node_modules/@otel-conformance/fetch-opentelemetry-fetch": { + "resolved": "fetch/opentelemetry-fetch", + "link": true + }, + "node_modules/@otel-conformance/fetch-scenarios": { + "resolved": "fetch/scenarios", + "link": true + }, "node_modules/@otel-conformance/http-test-client": { "version": "0.1.0", "resolved": "file:../../../tools/http/test-client/js", @@ -706,6 +1361,21 @@ "resolved": "undici/scenarios", "link": true }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1050,6 +1720,47 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1162,6 +1873,20 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1484,6 +2209,36 @@ "url": "https://opencollective.com/express" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/prettier": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", diff --git a/scenarios/http/js/package.json b/scenarios/http/js/package.json index 069fece57..4fab4dbce 100644 --- a/scenarios/http/js/package.json +++ b/scenarios/http/js/package.json @@ -6,11 +6,12 @@ "license": "Apache-2.0", "workspaces": [ "express/*", + "fetch/*", "http/*", "undici/*" ], "scripts": { - "test": "npm ci --ignore-scripts --prefix ../../../tools/http/test-client/js && npm --prefix ../../../tools/http/test-client/js test && npm --prefix ../../../tools/js/scenario-sdk test", + "test": "npm ci --ignore-scripts --prefix ../../../tools/http/test-client/js && npm --prefix ../../../tools/http/test-client/js test && npm --prefix ../../../tools/js/scenario-sdk test && node node_modules/@otel-conformance/browser-runner/test/index.test.js && node fetch/opentelemetry-fetch/test/client.test.js", "format": "prettier --write . ../../../tools/js ../../../tools/http/test-client/js", "format:check": "prettier --check . ../../../tools/js ../../../tools/http/test-client/js" }, diff --git a/tools/http/test-client/js/README.md b/tools/http/test-client/js/README.md index 3688675ee..1ab9d877d 100644 --- a/tools/http/test-client/js/README.md +++ b/tools/http/test-client/js/README.md @@ -1,12 +1,14 @@ -# HTTP conformance test client for Node +# HTTP conformance test client for JavaScript -The two halves of [the contract](../README.md) a Node scenario needs: answering -a concrete request, and sending the measured ones. +Shared [contract](../README.md) support for Node and browser scenarios. Node +scenarios use the request sender and server response helpers; browser scenarios +use the order-independent JSON comparison through the `json-equal` export. ```text src/contract.js reads contract.yaml src/server-workload.js answers one concrete request -src/client-workload.js sends one selected request +src/client-workload.js sends and checks one selected request +src/json-equal.js compares parsed JSON without object key ordering ``` The only dependency is the YAML parser used before the measured request. diff --git a/tools/http/test-client/js/package.json b/tools/http/test-client/js/package.json index 0b8deb33d..6f1c160f4 100644 --- a/tools/http/test-client/js/package.json +++ b/tools/http/test-client/js/package.json @@ -2,9 +2,13 @@ "name": "@otel-conformance/http-test-client", "version": "0.1.0", "private": true, - "description": "The HTTP conformance exchanges a Node scenario answers or sends.", + "description": "Shared HTTP conformance contract support for JavaScript scenarios.", "license": "Apache-2.0", "main": "src/index.js", + "exports": { + ".": "./src/index.js", + "./json-equal": "./src/json-equal.js" + }, "files": [ "src" ], diff --git a/tools/http/test-client/js/src/client-workload.js b/tools/http/test-client/js/src/client-workload.js index 072a6ca68..9d2858498 100644 --- a/tools/http/test-client/js/src/client-workload.js +++ b/tools/http/test-client/js/src/client-workload.js @@ -14,7 +14,8 @@ * The shared telemetry contract checks what these requests emit. */ -const { scenarioRequest } = require("./contract"); +const { renderResponseBody, scenarioRequest } = require("./contract"); +const { isJsonEqual } = require("./json-equal"); /** * Sends the runner-selected contract request at `baseUrl` through `send`. @@ -40,6 +41,29 @@ async function drive(baseUrl, send) { `${exchange.method} ${exchange.path} -> ${response.status} ` + `${abbreviate(response.body)}`, ); + verify(exchange, response); +} + +/** Checks one answer against the exchange that describes it. */ +function verify(exchange, response) { + const where = `${exchange.method} ${exchange.path}`; + if (response.status !== exchange.status) { + throw new Error( + `${where} answered ${response.status}, but the contract's request ` + + `answers ${exchange.status}`, + ); + } + + // Parsed, not compared as text: whitespace and key order are a language's + // choice of JSON writer, and neither is part of the contract. + const expected = JSON.parse(renderResponseBody(exchange, exchange.body)); + const actual = JSON.parse(response.body); + if (!isJsonEqual(actual, expected)) { + throw new Error( + `${where} answered ${JSON.stringify(actual)}, but the contract's ` + + `request answers ${JSON.stringify(expected)}`, + ); + } } function abbreviate(value) { diff --git a/tools/http/test-client/js/src/index.js b/tools/http/test-client/js/src/index.js index 715be99d1..ec607656b 100644 --- a/tools/http/test-client/js/src/index.js +++ b/tools/http/test-client/js/src/index.js @@ -15,10 +15,12 @@ const contract = require("./contract"); const clientWorkload = require("./client-workload"); +const jsonEqual = require("./json-equal"); const serverWorkload = require("./server-workload"); module.exports = { ...contract, ...clientWorkload, + ...jsonEqual, ...serverWorkload, }; diff --git a/tools/http/test-client/js/src/json-equal.js b/tools/http/test-client/js/src/json-equal.js new file mode 100644 index 000000000..2ed72a602 --- /dev/null +++ b/tools/http/test-client/js/src/json-equal.js @@ -0,0 +1,38 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +/** Compares parsed JSON values without assigning meaning to object key order. */ +function isJsonEqual(actual, expected) { + if (Object.is(actual, expected)) { + return true; + } + if (Array.isArray(actual) || Array.isArray(expected)) { + return ( + Array.isArray(actual) && + Array.isArray(expected) && + actual.length === expected.length && + actual.every((value, index) => isJsonEqual(value, expected[index])) + ); + } + if ( + actual === null || + expected === null || + typeof actual !== "object" || + typeof expected !== "object" + ) { + return false; + } + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + return ( + actualKeys.length === expectedKeys.length && + actualKeys.every( + (key) => + Object.hasOwn(expected, key) && isJsonEqual(actual[key], expected[key]), + ) + ); +} + +module.exports = { isJsonEqual }; diff --git a/tools/http/test-client/js/test/client-workload.test.js b/tools/http/test-client/js/test/client-workload.test.js index 529d99014..a871a101f 100644 --- a/tools/http/test-client/js/test/client-workload.test.js +++ b/tools/http/test-client/js/test/client-workload.test.js @@ -50,12 +50,22 @@ describe("driving the contract", () => { ]); }); - it("does not validate the response", async () => { - const previous = process.env[SCENARIO_INDEX_VARIABLE]; + it("rejects a response outside the contract", async () => { await withScenarioIndex(0, () => - drive(BASE_URL, () => ({ status: 599, body: "not json" })), + assert.rejects( + () => drive(BASE_URL, () => ({ status: 599, body: "not json" })), + /answered 599/, + ), + ); + }); + + it("accepts equivalent JSON with a different object key order", async () => { + await withScenarioIndex(0, () => + drive(BASE_URL, () => ({ + status: 200, + body: '{"name":"Alice","id":123}', + })), ); - assert.equal(process.env[SCENARIO_INDEX_VARIABLE], previous); }); it("refuses a blank base URL before anything is sent", async () => { diff --git a/tools/http/test-client/js/test/json-equal.test.js b/tools/http/test-client/js/test/json-equal.test.js new file mode 100644 index 000000000..910aeb940 --- /dev/null +++ b/tools/http/test-client/js/test/json-equal.test.js @@ -0,0 +1,24 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { isJsonEqual } = require("../src/json-equal"); + +test("ignores object key order at every nesting level", () => { + assert.equal( + isJsonEqual( + { result: { name: "Ada", roles: [{ active: true, name: "admin" }] } }, + { result: { roles: [{ name: "admin", active: true }], name: "Ada" } }, + ), + true, + ); +}); + +test("preserves array order and value types", () => { + assert.equal(isJsonEqual({ values: [1, 2] }, { values: [2, 1] }), false); + assert.equal(isJsonEqual({ value: 1 }, { value: "1" }), false); +}); diff --git a/tools/js/README.md b/tools/js/README.md index 60699ba5a..c494bfb19 100644 --- a/tools/js/README.md +++ b/tools/js/README.md @@ -8,6 +8,7 @@ itself, and the command that installs a Node build. scenarios//js/ a domain's npm build root — workspaces and the lockfile tools/js/scenario-support/ what a scenario needs before any telemetry tools/js/scenario-sdk/ the SDK a library-instrumentation scenario owns +tools/js/browser-runner/ browser launch and OTLP/HTTP-to-gRPC bridge tools/js/src/ `otel-conformance-js`, the launcher tools/js/tests/ the launcher's tests ``` diff --git a/tools/js/browser-runner/index.js b/tools/js/browser-runner/index.js new file mode 100644 index 000000000..48392dcca --- /dev/null +++ b/tools/js/browser-runner/index.js @@ -0,0 +1,219 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +/** + * Starts a browser workload and forwards its OTLP/HTTP protobuf exports to + * the conformance runner's OTLP/gRPC collector. + * + * A browser cannot speak OTLP/gRPC directly. The bridge gives its bundled + * page a same-origin OTLP/HTTP endpoint, then carries the protobuf request + * unchanged over gRPC. Every path except the bridge's own endpoints proxies + * unchanged to the runner's mock server, so Fetch observes the contract's + * exact paths without a cross-origin preflight. + */ + +const http = require("node:http"); +const { URL } = require("node:url"); + +const grpc = require("@grpc/grpc-js"); +const { chromium } = require("@playwright/test"); +const esbuild = require("esbuild"); + +const TRACE_EXPORT_PATH = + "/opentelemetry.proto.collector.trace.v1.TraceService/Export"; +const BRIDGE_PATH = "/_otel-conformance"; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`required environment variable is missing: ${name}`); + } + return value; +} + +function requireUrl(name) { + const value = requireEnv(name); + try { + const url = new URL(value); + if (!url.host) { + throw new Error("URL must include a host"); + } + return url; + } catch (error) { + throw new Error(`required environment variable is not a URL: ${name}`, { + cause: error, + }); + } +} + +function grpcTraceClient(endpoint) { + const address = endpoint.host; + const TraceService = grpc.makeGenericClientConstructor( + { + export: { + path: TRACE_EXPORT_PATH, + requestStream: false, + responseStream: false, + requestSerialize: (value) => value, + requestDeserialize: (value) => value, + responseSerialize: (value) => value, + responseDeserialize: (value) => value, + }, + }, + "TraceService", + ); + return new TraceService(address, grpc.credentials.createInsecure()); +} + +function forward(client, payload) { + return new Promise((resolve, reject) => { + client.export(payload, (error) => (error ? reject(error) : resolve())); + }); +} + +function read(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function proxy(request, response, mockServer) { + const target = new URL(request.url, mockServer); + const upstream = http.request(target, { + method: request.method, + headers: { ...request.headers, host: target.host }, + }); + upstream.on("response", (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode, upstreamResponse.headers); + upstreamResponse.pipe(response); + }); + upstream.on("error", (error) => { + response.writeHead(502, { "content-type": "text/plain" }); + response.end(error.message); + }); + request.pipe(upstream); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(server.address().port); + }); + }); +} + +function close(server) { + server.closeAllConnections?.(); + return new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} + +/** Prefer a locally installed Chrome, then Playwright's managed Chromium. */ +async function launchBrowser(browserType = chromium) { + try { + return await browserType.launch({ channel: "chrome", headless: true }); + } catch { + return browserType.launch({ headless: true }); + } +} + +async function bundle(entrypoint) { + const result = await esbuild.build({ + bundle: true, + entryPoints: [entrypoint], + format: "iife", + platform: "browser", + target: "es2022", + write: false, + }); + return result.outputFiles[0].text; +} + +/** Runs one bundled browser workload against the shared HTTP contract. */ +async function runBrowserScenario({ entrypoint, exchanges }) { + const mockServer = requireUrl("MOCK_SERVER_URL"); + const collectorEndpoint = requireUrl("OTEL_EXPORTER_OTLP_ENDPOINT"); + const collector = grpcTraceClient(collectorEndpoint); + let server; + let browser; + try { + const script = await bundle(entrypoint); + let bridgeUrl; + server = http.createServer(async (request, response) => { + try { + if (request.method === "GET" && request.url === "/") { + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + }); + response.end( + ``, + ); + return; + } + if ( + request.method === "GET" && + request.url === `${BRIDGE_PATH}/scenario.js` + ) { + response.writeHead(200, { + "content-type": "text/javascript; charset=utf-8", + }); + response.end( + `window.__otelConformance = ${JSON.stringify({ + baseUrl: bridgeUrl, + traceExporterUrl: `${bridgeUrl}${BRIDGE_PATH}/v1/traces`, + exchanges, + })};\n${script}`, + ); + return; + } + if ( + request.method === "POST" && + request.url === `${BRIDGE_PATH}/v1/traces` + ) { + await forward(collector, await read(request)); + response.writeHead(200); + response.end(); + return; + } + proxy(request, response, mockServer); + } catch (error) { + response.writeHead(500, { "content-type": "text/plain" }); + response.end(error instanceof Error ? error.stack : String(error)); + } + }); + const port = await listen(server); + bridgeUrl = `http://127.0.0.1:${port}`; + browser = await launchBrowser(); + const page = await browser.newPage(); + await page.goto(bridgeUrl); + await page.waitForFunction( + () => window.__otelConformanceResult !== undefined, + ); + const result = await page.evaluate(() => window.__otelConformanceResult); + if (!result.ok) { + throw new Error(result.error); + } + } finally { + try { + await browser?.close(); + } finally { + try { + if (server?.listening) { + await close(server); + } + } finally { + collector.close(); + } + } + } +} + +module.exports = { launchBrowser, runBrowserScenario }; diff --git a/tools/js/browser-runner/package.json b/tools/js/browser-runner/package.json new file mode 100644 index 000000000..638359daf --- /dev/null +++ b/tools/js/browser-runner/package.json @@ -0,0 +1,16 @@ +{ + "name": "@otel-conformance/browser-runner", + "version": "0.1.0", + "private": true, + "description": "Runs a bundled browser conformance workload and bridges its OTLP telemetry.", + "license": "Apache-2.0", + "main": "index.js", + "scripts": { + "test": "node test/index.test.js" + }, + "dependencies": { + "@grpc/grpc-js": "1.14.4", + "@playwright/test": "1.62.1", + "esbuild": "0.28.2" + } +} diff --git a/tools/js/browser-runner/test/fixtures/browser.js b/tools/js/browser-runner/test/fixtures/browser.js new file mode 100644 index 000000000..9dab02afb --- /dev/null +++ b/tools/js/browser-runner/test/fixtures/browser.js @@ -0,0 +1,17 @@ +window + .fetch(`${window.__otelConformance.baseUrl}/example`) + .then(async (response) => { + if ((await response.text()) !== "contract response") { + throw new Error("the bridge did not proxy the contract response"); + } + return window.fetch(window.__otelConformance.traceExporterUrl, { + method: "POST", + body: new Uint8Array([1, 2, 3]), + }); + }) + .then(() => { + window.__otelConformanceResult = { ok: true }; + }) + .catch((error) => { + window.__otelConformanceResult = { ok: false, error: String(error) }; + }); diff --git a/tools/js/browser-runner/test/index.test.js b/tools/js/browser-runner/test/index.test.js new file mode 100644 index 000000000..69b8272d4 --- /dev/null +++ b/tools/js/browser-runner/test/index.test.js @@ -0,0 +1,137 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const assert = require("node:assert/strict"); +const http = require("node:http"); +const path = require("node:path"); +const test = require("node:test"); + +const grpc = require("@grpc/grpc-js"); + +const { launchBrowser, runBrowserScenario } = require("../index"); + +const TRACE_EXPORT_PATH = + "/opentelemetry.proto.collector.trace.v1.TraceService/Export"; + +const service = { + export: { + path: TRACE_EXPORT_PATH, + requestStream: false, + responseStream: false, + requestSerialize: (value) => value, + requestDeserialize: (value) => value, + responseSerialize: (value) => value, + responseDeserialize: (value) => value, + }, +}; + +function listen(server) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function close(server) { + server.closeAllConnections?.(); + return new Promise((resolve) => server.close(resolve)); +} + +function bind(server) { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => (error ? reject(error) : resolve(port)), + ); + }); +} + +test("falls back to Playwright's managed Chromium", async () => { + const launches = []; + const managed = { close() {} }; + const browserType = { + async launch(options) { + launches.push(options); + if (options.channel === "chrome") { + throw new Error("Chrome is not installed"); + } + return managed; + }, + }; + + assert.equal(await launchBrowser(browserType), managed); + assert.deepEqual(launches, [ + { channel: "chrome", headless: true }, + { headless: true }, + ]); +}); + +test("validates the mock URL before constructing a collector client", async () => { + const original = Object.getOwnPropertyDescriptor( + grpc, + "makeGenericClientConstructor", + ); + let constructed = false; + const previous = { + mock: process.env.MOCK_SERVER_URL, + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + }; + Object.defineProperty(grpc, "makeGenericClientConstructor", { + configurable: true, + value: () => { + constructed = true; + }, + }); + process.env.MOCK_SERVER_URL = "not a URL"; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://127.0.0.1:4317"; + try { + await assert.rejects( + runBrowserScenario({ entrypoint: "unused.js", exchanges: [] }), + /MOCK_SERVER_URL/, + ); + } finally { + Object.defineProperty(grpc, "makeGenericClientConstructor", original); + process.env.MOCK_SERVER_URL = previous.mock; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = previous.endpoint; + } + assert.equal(constructed, false); +}); + +test("proxies contract traffic and forwards browser OTLP protobuf", async () => { + const mock = http.createServer((request, response) => { + assert.equal(request.url, "/example"); + response.end("contract response"); + }); + await listen(mock); + const mockPort = mock.address().port; + + let telemetry; + const collector = new grpc.Server(); + collector.addService(service, { + export(call, callback) { + telemetry = call.request; + callback(null, Buffer.alloc(0)); + }, + }); + const collectorPort = await bind(collector); + collector.start(); + + const previous = { + mock: process.env.MOCK_SERVER_URL, + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + }; + process.env.MOCK_SERVER_URL = `http://127.0.0.1:${mockPort}`; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = `http://127.0.0.1:${collectorPort}`; + try { + await runBrowserScenario({ + entrypoint: path.join(__dirname, "fixtures", "browser.js"), + exchanges: [], + }); + } finally { + process.env.MOCK_SERVER_URL = previous.mock; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = previous.endpoint; + await close(mock); + collector.forceShutdown(); + } + assert.deepEqual(telemetry, Buffer.from([1, 2, 3])); +});