From 1d5d3119a4ebb37d8a78a1ae898cc61ecc107151 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 22 Jun 2026 16:52:19 +0200 Subject: [PATCH 1/7] ref: Fork orchestrion-js --- integrations/otel-js/otel-v1/pnpm-lock.yaml | 93 +++- integrations/otel-js/otel-v2/pnpm-lock.yaml | 93 +++- js/NOTICE | 12 + js/licenses/orchestrion-js/LICENSE | 202 ++++++++ .../orchestrion-js/LICENSE-3rdparty.csv | 8 + js/licenses/orchestrion-js/NOTICE | 3 + js/package.json | 10 +- js/src/auto-instrumentations/README.md | 4 +- .../auto-instrumentations/apm-js-collab.d.ts | 5 - .../auto-instrumentations/bundler/esbuild.ts | 1 + .../auto-instrumentations/bundler/plugin.ts | 5 +- .../auto-instrumentations/bundler/rollup.ts | 1 + js/src/auto-instrumentations/bundler/vite.ts | 1 + .../bundler/webpack-loader.ts | 26 +- .../auto-instrumentations/bundler/webpack.ts | 1 + .../auto-instrumentations/configs/ai-sdk.ts | 2 +- js/src/auto-instrumentations/configs/all.ts | 2 +- .../configs/anthropic.ts | 2 +- .../configs/claude-agent-sdk.ts | 2 +- .../auto-instrumentations/configs/cohere.ts | 2 +- .../configs/cursor-sdk.ts | 2 +- js/src/auto-instrumentations/configs/flue.ts | 2 +- .../auto-instrumentations/configs/genkit.ts | 2 +- .../configs/github-copilot.ts | 2 +- .../configs/google-adk.ts | 2 +- .../configs/google-genai.ts | 2 +- js/src/auto-instrumentations/configs/groq.ts | 2 +- .../configs/huggingface.ts | 2 +- .../configs/langchain.ts | 2 +- .../auto-instrumentations/configs/mistral.ts | 2 +- .../configs/openai-agents.ts | 2 +- .../configs/openai-codex.ts | 2 +- .../auto-instrumentations/configs/openai.ts | 2 +- .../configs/openrouter-agent.ts | 2 +- .../configs/openrouter.ts | 2 +- .../configs/pi-coding-agent.ts | 2 +- js/src/auto-instrumentations/index.ts | 6 +- .../auto-instrumentations/loader/cjs-patch.ts | 7 +- .../auto-instrumentations/loader/esm-hook.mts | 7 +- .../loader/special-case-patches.ts | 2 +- .../orchestrion-js/index.ts | 19 + .../orchestrion-js/matcher.ts | 62 +++ .../orchestrion-js/transformer.ts | 282 +++++++++++ .../orchestrion-js/transforms.ts | 447 +++++++++++++++++ .../orchestrion-js/types.ts | 106 ++++ .../orchestrion-js/vendor-deps.d.ts | 22 + js/src/instrumentation/README.md | 4 +- js/src/instrumentation/core/index.ts | 4 +- js/src/instrumentation/index.ts | 4 +- .../fixtures/orchestrion-js/README.md | 8 + .../orchestrion-js/arguments_mutation/mod.js | 34 ++ .../orchestrion-js/arguments_mutation/test.js | 47 ++ .../orchestrion-js/ast_query_cjs/mod.js | 10 + .../orchestrion-js/ast_query_cjs/test.js | 18 + .../orchestrion-js/callback_cjs/mod.js | 12 + .../orchestrion-js/callback_cjs/test.js | 23 + .../class_expression_cjs/mod.js | 16 + .../class_expression_cjs/test.js | 18 + .../orchestrion-js/class_method_cjs/mod.js | 16 + .../orchestrion-js/class_method_cjs/test.js | 18 + .../orchestrion-js/common/preamble.js | 29 ++ .../const_class_export_alias_mjs/mod.mjs | 11 + .../const_class_export_alias_mjs/test.mjs | 16 + .../fixtures/orchestrion-js/decl_cjs/mod.js | 9 + .../fixtures/orchestrion-js/decl_cjs/test.js | 17 + .../fixtures/orchestrion-js/decl_mjs/mod.mjs | 9 + .../fixtures/orchestrion-js/decl_mjs/test.mjs | 15 + .../decl_mjs_mismatched_type/mod.mjs | 9 + .../decl_mjs_mismatched_type/test.mjs | 15 + .../export_alias_class_mjs/mod.mjs | 11 + .../export_alias_class_mjs/test.mjs | 16 + .../orchestrion-js/export_alias_mjs/mod.mjs | 9 + .../orchestrion-js/export_alias_mjs/test.mjs | 15 + .../orchestrion-js/iife_nested_class/mod.js | 18 + .../orchestrion-js/iife_nested_class/test.js | 16 + .../fixtures/orchestrion-js/index_cjs/mod.js | 49 ++ .../fixtures/orchestrion-js/index_cjs/test.js | 31 ++ .../instance_method_subclass_cjs/mod.js | 13 + .../instance_method_subclass_cjs/test.js | 18 + .../let_class_export_alias_mjs/mod.mjs | 11 + .../let_class_export_alias_mjs/test.mjs | 16 + .../multiple_class_method_cjs/mod.js | 15 + .../multiple_class_method_cjs/test.js | 28 ++ .../orchestrion-js/multiple_load_cjs/mod.js | 16 + .../orchestrion-js/multiple_load_cjs/test.js | 18 + .../orchestrion-js/nested_functions/mod.js | 18 + .../orchestrion-js/nested_functions/test.js | 16 + .../orchestrion-js/object_method_cjs/mod.js | 11 + .../orchestrion-js/object_method_cjs/test.js | 17 + .../object_property_named_cjs/mod.js | 14 + .../object_property_named_cjs/test.js | 20 + .../object_property_this_cjs/mod.js | 15 + .../object_property_this_cjs/test.js | 21 + .../orchestrion-js/polyfill_cjs/mod.js | 9 + .../orchestrion-js/polyfill_cjs/polyfill.js | 8 + .../orchestrion-js/polyfill_cjs/test.js | 19 + .../orchestrion-js/polyfill_mjs/mod.mjs | 9 + .../orchestrion-js/polyfill_mjs/polyfill.js | 9 + .../orchestrion-js/polyfill_mjs/test.mjs | 17 + .../orchestrion-js/private_method_cjs/mod.js | 19 + .../orchestrion-js/private_method_cjs/test.js | 18 + .../orchestrion-js/promise_subclass/mod.js | 27 + .../orchestrion-js/promise_subclass/test.js | 25 + .../var_class_export_alias_mjs/mod.mjs | 11 + .../var_class_export_alias_mjs/test.mjs | 16 + .../var_named_class_export_alias_mjs/mod.mjs | 17 + .../var_named_class_export_alias_mjs/test.mjs | 16 + .../orchestrion-js/windows_path/mod.js | 9 + .../orchestrion-js/windows_path/test.js | 17 + .../wrap_promise_non_promise/mod.js | 9 + .../wrap_promise_non_promise/test.js | 13 + .../orchestrion-js-upstream.test.ts | 470 ++++++++++++++++++ .../transformation.test.ts | 241 ++++++++- js/tsup.config.ts | 6 +- pnpm-lock.yaml | 30 +- pnpm-workspace.yaml | 1 - 116 files changed, 3135 insertions(+), 142 deletions(-) create mode 100644 js/NOTICE create mode 100644 js/licenses/orchestrion-js/LICENSE create mode 100644 js/licenses/orchestrion-js/LICENSE-3rdparty.csv create mode 100644 js/licenses/orchestrion-js/NOTICE delete mode 100644 js/src/auto-instrumentations/apm-js-collab.d.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/index.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/matcher.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/transformer.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/transforms.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/types.ts create mode 100644 js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/README.md create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/common/preamble.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/polyfill.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/polyfill.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/mod.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/test.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/test.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/mod.js create mode 100644 js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/test.js create mode 100644 js/tests/auto-instrumentations/orchestrion-js-upstream.test.ts diff --git a/integrations/otel-js/otel-v1/pnpm-lock.yaml b/integrations/otel-js/otel-v1/pnpm-lock.yaml index 6a755bf70..303cadff8 100644 --- a/integrations/otel-js/otel-v1/pnpm-lock.yaml +++ b/integrations/otel-js/otel-v1/pnpm-lock.yaml @@ -78,8 +78,43 @@ importers: packages: - '@apm-js-collab/code-transformer@0.12.0': - resolution: {integrity: sha512-5F2ob4cMYezbaUGAk+YltbDvb9BFIghN92ubct9Ho/0MFx4FkChCxYV99NkU6Kx+RAgaqBV6yxKuWreQ6K8SOw==} + '@braintrust/bt-darwin-arm64@0.12.0': + resolution: {integrity: sha512-mY6VW/3VwcOQOGN8sYHS6F0xzHTFwgZcNlj7zlQttI6OXOCGt/bhonGIqd03QBhxmj0M31ymSS7TqSeCK/RYIQ==} + cpu: [arm64] + os: [darwin] + + '@braintrust/bt-darwin-x64@0.12.0': + resolution: {integrity: sha512-woyRyDv2DfCF8+von+3X9f1cddAbFnKLSfCbEkrBQVc0ZsAM60as8nehYv7DkafJF/67yKFx/sUraV65sukesQ==} + cpu: [x64] + os: [darwin] + + '@braintrust/bt-linux-arm64@0.12.0': + resolution: {integrity: sha512-J9/7f3EIMKmFmSSQHQnAQLpUgB8YJENrOlkABjlTprBgsIYVgz7tSH7yg2P3ur+2Jem7PpGnrDxHGh/UjRfjsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@braintrust/bt-linux-x64-musl@0.12.0': + resolution: {integrity: sha512-KnLgENOoztBXcH+mLFJe4bYzi67kSOuELOQrm4Hler35GjgaBMI1fFLIUjKRPAmyT9VIhBMhy1ICfGEFLueMEQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@braintrust/bt-linux-x64@0.12.0': + resolution: {integrity: sha512-HJFbUl3HYYY1Ivw8OYBeIMcIsU9r7+fC9BzLWB5FdH7881ToKee2wbbLZssMCxFbMVeUxzEo+SzGD/1Z9Gk9CA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@braintrust/bt-win32-arm64@0.12.0': + resolution: {integrity: sha512-+7PkdBAmiqEJsWteGHP/Zs62F75PkHPA8m/ej4TOz/mHGKulUU0bZibw91KRqIB7J7jGi5ItvCiqkiGaLKLnyw==} + cpu: [arm64] + os: [win32] + + '@braintrust/bt-win32-x64@0.12.0': + resolution: {integrity: sha512-Q0c+pVUGm82n/7N8QJ5s6j/G/Q0GFzqOaTipHjkmElLbAOOEcfRH/uljdtIPNBiEQcrzqirS0GmOgiFkeQ3Txg==} + cpu: [x64] + os: [win32] '@braintrust/otel@file:..': resolution: {directory: .., type: directory} @@ -1351,9 +1386,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.14.0: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1793,10 +1825,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -2082,14 +2110,26 @@ packages: snapshots: - '@apm-js-collab/code-transformer@0.12.0': - dependencies: - '@types/estree': 1.0.9 - astring: 1.9.0 - esquery: 1.7.0 - meriyah: 6.1.4 - semifies: 1.0.0 - source-map: 0.6.1 + '@braintrust/bt-darwin-arm64@0.12.0': + optional: true + + '@braintrust/bt-darwin-x64@0.12.0': + optional: true + + '@braintrust/bt-linux-arm64@0.12.0': + optional: true + + '@braintrust/bt-linux-x64-musl@0.12.0': + optional: true + + '@braintrust/bt-linux-x64@0.12.0': + optional: true + + '@braintrust/bt-win32-arm64@0.12.0': + optional: true + + '@braintrust/bt-win32-x64@0.12.0': + optional: true '@braintrust/otel@file:..(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.9.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.9.0(@opentelemetry/api@1.9.0))(braintrust@file:../../../js(zod@4.4.3))': dependencies: @@ -2874,25 +2914,28 @@ snapshots: braintrust@file:../../../js(zod@4.4.3): dependencies: - '@apm-js-collab/code-transformer': 0.12.0 '@next/env': 14.2.35 + '@types/estree': 1.0.9 '@vercel/functions': 1.6.0 ajv: 8.20.0 argparse: 2.0.1 + astring: 1.9.0 cli-progress: 3.12.0 cli-table3: 0.6.5 cors: 2.8.6 dc-browser: 1.0.4 dotenv: 16.6.1 esbuild: 0.28.0 + esquery: 1.7.0 eventsource-parser: 1.1.2 express: 5.2.1 - graceful-fs: 4.2.11 http-errors: 2.0.1 + meriyah: 6.1.4 minimatch: 10.2.5 module-details-from-path: 1.0.4 mustache: 4.2.0 pluralize: 8.0.0 + semifies: 1.0.0 simple-git: 3.36.0 source-map: 0.7.6 termi-link: 1.1.0 @@ -2900,6 +2943,14 @@ snapshots: uuid: 11.1.1 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@braintrust/bt-darwin-arm64': 0.12.0 + '@braintrust/bt-darwin-x64': 0.12.0 + '@braintrust/bt-linux-arm64': 0.12.0 + '@braintrust/bt-linux-x64': 0.12.0 + '@braintrust/bt-linux-x64-musl': 0.12.0 + '@braintrust/bt-win32-arm64': 0.12.0 + '@braintrust/bt-win32-x64': 0.12.0 transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - supports-color @@ -3201,8 +3252,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - graphql@16.14.0: {} has-symbols@1.1.0: {} @@ -3657,8 +3706,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - source-map@0.7.6: {} stackback@0.0.2: {} diff --git a/integrations/otel-js/otel-v2/pnpm-lock.yaml b/integrations/otel-js/otel-v2/pnpm-lock.yaml index dae7e6c03..81d409b08 100644 --- a/integrations/otel-js/otel-v2/pnpm-lock.yaml +++ b/integrations/otel-js/otel-v2/pnpm-lock.yaml @@ -72,8 +72,43 @@ importers: packages: - '@apm-js-collab/code-transformer@0.12.0': - resolution: {integrity: sha512-5F2ob4cMYezbaUGAk+YltbDvb9BFIghN92ubct9Ho/0MFx4FkChCxYV99NkU6Kx+RAgaqBV6yxKuWreQ6K8SOw==} + '@braintrust/bt-darwin-arm64@0.12.0': + resolution: {integrity: sha512-mY6VW/3VwcOQOGN8sYHS6F0xzHTFwgZcNlj7zlQttI6OXOCGt/bhonGIqd03QBhxmj0M31ymSS7TqSeCK/RYIQ==} + cpu: [arm64] + os: [darwin] + + '@braintrust/bt-darwin-x64@0.12.0': + resolution: {integrity: sha512-woyRyDv2DfCF8+von+3X9f1cddAbFnKLSfCbEkrBQVc0ZsAM60as8nehYv7DkafJF/67yKFx/sUraV65sukesQ==} + cpu: [x64] + os: [darwin] + + '@braintrust/bt-linux-arm64@0.12.0': + resolution: {integrity: sha512-J9/7f3EIMKmFmSSQHQnAQLpUgB8YJENrOlkABjlTprBgsIYVgz7tSH7yg2P3ur+2Jem7PpGnrDxHGh/UjRfjsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@braintrust/bt-linux-x64-musl@0.12.0': + resolution: {integrity: sha512-KnLgENOoztBXcH+mLFJe4bYzi67kSOuELOQrm4Hler35GjgaBMI1fFLIUjKRPAmyT9VIhBMhy1ICfGEFLueMEQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@braintrust/bt-linux-x64@0.12.0': + resolution: {integrity: sha512-HJFbUl3HYYY1Ivw8OYBeIMcIsU9r7+fC9BzLWB5FdH7881ToKee2wbbLZssMCxFbMVeUxzEo+SzGD/1Z9Gk9CA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@braintrust/bt-win32-arm64@0.12.0': + resolution: {integrity: sha512-+7PkdBAmiqEJsWteGHP/Zs62F75PkHPA8m/ej4TOz/mHGKulUU0bZibw91KRqIB7J7jGi5ItvCiqkiGaLKLnyw==} + cpu: [arm64] + os: [win32] + + '@braintrust/bt-win32-x64@0.12.0': + resolution: {integrity: sha512-Q0c+pVUGm82n/7N8QJ5s6j/G/Q0GFzqOaTipHjkmElLbAOOEcfRH/uljdtIPNBiEQcrzqirS0GmOgiFkeQ3Txg==} + cpu: [x64] + os: [win32] '@braintrust/otel@file:..': resolution: {directory: .., type: directory} @@ -1329,9 +1364,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.14.0: resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1739,10 +1771,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -2013,14 +2041,26 @@ packages: snapshots: - '@apm-js-collab/code-transformer@0.12.0': - dependencies: - '@types/estree': 1.0.9 - astring: 1.9.0 - esquery: 1.7.0 - meriyah: 6.1.4 - semifies: 1.0.0 - source-map: 0.6.1 + '@braintrust/bt-darwin-arm64@0.12.0': + optional: true + + '@braintrust/bt-darwin-x64@0.12.0': + optional: true + + '@braintrust/bt-linux-arm64@0.12.0': + optional: true + + '@braintrust/bt-linux-x64-musl@0.12.0': + optional: true + + '@braintrust/bt-linux-x64@0.12.0': + optional: true + + '@braintrust/bt-win32-arm64@0.12.0': + optional: true + + '@braintrust/bt-win32-x64@0.12.0': + optional: true '@braintrust/otel@file:..(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.0.0(@opentelemetry/api@1.9.0))(@opentelemetry/exporter-trace-otlp-http@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.0(@opentelemetry/api@1.9.0))(braintrust@file:../../../js(zod@4.4.3))': dependencies: @@ -2819,25 +2859,28 @@ snapshots: braintrust@file:../../../js(zod@4.4.3): dependencies: - '@apm-js-collab/code-transformer': 0.12.0 '@next/env': 14.2.35 + '@types/estree': 1.0.9 '@vercel/functions': 1.6.0 ajv: 8.20.0 argparse: 2.0.1 + astring: 1.9.0 cli-progress: 3.12.0 cli-table3: 0.6.5 cors: 2.8.6 dc-browser: 1.0.4 dotenv: 16.6.1 esbuild: 0.28.0 + esquery: 1.7.0 eventsource-parser: 1.1.2 express: 5.2.1 - graceful-fs: 4.2.11 http-errors: 2.0.1 + meriyah: 6.1.4 minimatch: 10.2.5 module-details-from-path: 1.0.4 mustache: 4.2.0 pluralize: 8.0.0 + semifies: 1.0.0 simple-git: 3.36.0 source-map: 0.7.6 termi-link: 1.1.0 @@ -2845,6 +2888,14 @@ snapshots: uuid: 11.1.1 zod: 4.4.3 zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@braintrust/bt-darwin-arm64': 0.12.0 + '@braintrust/bt-darwin-x64': 0.12.0 + '@braintrust/bt-linux-arm64': 0.12.0 + '@braintrust/bt-linux-x64': 0.12.0 + '@braintrust/bt-linux-x64-musl': 0.12.0 + '@braintrust/bt-win32-arm64': 0.12.0 + '@braintrust/bt-win32-x64': 0.12.0 transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - supports-color @@ -3136,8 +3187,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - graphql@16.14.0: {} has-symbols@1.1.0: {} @@ -3570,8 +3619,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - source-map@0.7.6: {} stackback@0.0.2: {} diff --git a/js/NOTICE b/js/NOTICE new file mode 100644 index 000000000..31de6cfcf --- /dev/null +++ b/js/NOTICE @@ -0,0 +1,12 @@ +Braintrust JavaScript SDK + +This package includes a modified fork of Orchestrion-JS / +@apm-js-collab/code-transformer 0.12.0. + +Orchestrion-JS +Copyright 2025-Present Datadog, Inc. +This product includes software developed at Datadog (). + +The Orchestrion-JS fork is licensed under the Apache License, Version 2.0. +See licenses/orchestrion-js/LICENSE and licenses/orchestrion-js/NOTICE. + diff --git a/js/licenses/orchestrion-js/LICENSE b/js/licenses/orchestrion-js/LICENSE new file mode 100644 index 000000000..eaa52b855 --- /dev/null +++ b/js/licenses/orchestrion-js/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025-present Datadog, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/js/licenses/orchestrion-js/LICENSE-3rdparty.csv b/js/licenses/orchestrion-js/LICENSE-3rdparty.csv new file mode 100644 index 000000000..4ee93704f --- /dev/null +++ b/js/licenses/orchestrion-js/LICENSE-3rdparty.csv @@ -0,0 +1,8 @@ +Component,Origin,License,Copyright +@types/estree,https://github.com/DefinitelyTyped/DefinitelyTyped,MIT,Copyright (c) Microsoft Corporation. +astring,https://github.com/davidbonnet/astring,MIT,"Copyright (c) 2015, David Bonnet" +esquery,https://github.com/estools/esquery,BSD-3-Clause,"Copyright (c) 2013, Joel Feenstra" +estraverse,https://github.com/estools/estraverse,BSD-2-Clause,Copyright (c) 2012-2016 Yusuke Suzuki and other contributors +meriyah,https://github.com/meriyah/meriyah,ISC,"Copyright (c) 2019 and later, KFlash and others." +semifies,https://github.com/holepunchto/semifies,Apache-2.0,Copyright Holepunch Inc +source-map,https://github.com/mozilla/source-map,BSD-3-Clause,Copyright (c) 2009-2011, Mozilla Foundation and contributors diff --git a/js/licenses/orchestrion-js/NOTICE b/js/licenses/orchestrion-js/NOTICE new file mode 100644 index 000000000..285f69f9a --- /dev/null +++ b/js/licenses/orchestrion-js/NOTICE @@ -0,0 +1,3 @@ +Orchestrion-JS +Copyright 2025-Present Datadog, Inc. +This product includes software developed at Datadog (). diff --git a/js/package.json b/js/package.json index 123468198..9df2f1ff8 100644 --- a/js/package.json +++ b/js/package.json @@ -131,7 +131,9 @@ "util/dist/**/*", "bin/bt", "scripts/bt-helper.js", - "scripts/install.js" + "scripts/install.js", + "NOTICE", + "licenses/**/*" ], "scripts": { "postinstall": "node ./scripts/install.js", @@ -201,24 +203,28 @@ "zod": "^3.25.34" }, "dependencies": { - "@apm-js-collab/code-transformer": "^0.12.0", "@next/env": "^14.2.3", + "@types/estree": "^1.0.8", "@vercel/functions": "^1.0.2", "ajv": "^8.20.0", "argparse": "^2.0.1", + "astring": "^1.9.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "cors": "^2.8.5", "dc-browser": "^1.0.4", "dotenv": "^16.4.5", "esbuild": "0.28.0", + "esquery": "^1.7.0", "eventsource-parser": "^1.1.2", "express": "^5.2.1", "http-errors": "^2.0.0", + "meriyah": "^6.1.4", "minimatch": "^10.2.5", "module-details-from-path": "^1.0.4", "mustache": "^4.2.0", "pluralize": "^8.0.0", + "semifies": "^1.0.0", "simple-git": "^3.36.0", "source-map": "^0.7.4", "termi-link": "^1.0.1", diff --git a/js/src/auto-instrumentations/README.md b/js/src/auto-instrumentations/README.md index 5ce49ff84..f2a319210 100644 --- a/js/src/auto-instrumentations/README.md +++ b/js/src/auto-instrumentations/README.md @@ -4,7 +4,7 @@ This document describes the internal architecture of the Braintrust auto-instrum ## Architecture Overview -The auto-instrumentation system uses [orchestrion-js](https://github.com/apm-js-collab/orchestrion-js) to perform AST transformations at load-time or build-time, injecting `diagnostics_channel` instrumentation into AI SDK methods. +The auto-instrumentation system uses an internal fork of [orchestrion-js](https://github.com/nodejs/orchestrion-js) to perform AST transformations at load-time or build-time, injecting `diagnostics_channel` instrumentation into AI SDK methods. ## How It Works @@ -165,7 +165,7 @@ Configuration is merged in the following order (later overrides earlier): 1. **Create a config file** in `src/configs/` (e.g., `anthropic.ts`): ```typescript -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "braintrust/vite"; export const anthropicConfigs: InstrumentationConfig[] = [ { diff --git a/js/src/auto-instrumentations/apm-js-collab.d.ts b/js/src/auto-instrumentations/apm-js-collab.d.ts deleted file mode 100644 index 2a7bdfa22..000000000 --- a/js/src/auto-instrumentations/apm-js-collab.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Type declarations for @apm-js-collab packages - */ - -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; diff --git a/js/src/auto-instrumentations/bundler/esbuild.ts b/js/src/auto-instrumentations/bundler/esbuild.ts index 85c0d0375..a7ff2d64f 100644 --- a/js/src/auto-instrumentations/bundler/esbuild.ts +++ b/js/src/auto-instrumentations/bundler/esbuild.ts @@ -4,6 +4,7 @@ import { unplugin, type LegacyBundlerPluginOptions, } from "./plugin"; +export type { InstrumentationConfig } from "../orchestrion-js"; export function braintrustEsbuildPlugin( options: BundlerPluginOptions = {}, diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index 3cb72fbc6..dda1b7490 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -1,8 +1,5 @@ import { createUnplugin } from "unplugin"; -import { - create, - type InstrumentationConfig, -} from "@apm-js-collab/code-transformer"; +import { create, type InstrumentationConfig } from "../orchestrion-js"; import { extname, join, sep } from "path"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; diff --git a/js/src/auto-instrumentations/bundler/rollup.ts b/js/src/auto-instrumentations/bundler/rollup.ts index 981bff88c..f0552cb26 100644 --- a/js/src/auto-instrumentations/bundler/rollup.ts +++ b/js/src/auto-instrumentations/bundler/rollup.ts @@ -4,6 +4,7 @@ import { unplugin, type LegacyBundlerPluginOptions, } from "./plugin"; +export type { InstrumentationConfig } from "../orchestrion-js"; export function braintrustRollupPlugin( options: BundlerPluginOptions = {}, diff --git a/js/src/auto-instrumentations/bundler/vite.ts b/js/src/auto-instrumentations/bundler/vite.ts index 427e16bee..6faec7cd0 100644 --- a/js/src/auto-instrumentations/bundler/vite.ts +++ b/js/src/auto-instrumentations/bundler/vite.ts @@ -4,6 +4,7 @@ import { unplugin, type LegacyBundlerPluginOptions, } from "./plugin"; +export type { InstrumentationConfig } from "../orchestrion-js"; export function braintrustVitePlugin( options: BundlerPluginOptions = {}, diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index 1f18fcddb..c218c21a6 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -21,11 +21,7 @@ * ``` */ -import { - create, - type InstrumentationMatcher, - type ModuleType, -} from "@apm-js-collab/code-transformer"; +import { create } from "../orchestrion-js"; import { extname, join, sep } from "path"; import { readFileSync } from "fs"; import moduleDetailsFromPath from "module-details-from-path"; @@ -49,15 +45,16 @@ function getModuleVersion(basedir: string): string | undefined { return undefined; } -// Matcher cache keyed by config hash for cache invalidation -const matcherCache = new Map(); +type Matcher = ReturnType; +type ModuleType = "esm" | "cjs"; + +// Matcher cache keyed by config hash for cache invalidation. +const matcherCache = new Map(); /** * Get or create a matcher instance, caching by config hash */ -function getMatcher( - options: LegacyBundlerPluginOptions, -): InstrumentationMatcher { +function getMatcher(options: LegacyBundlerPluginOptions): Matcher { const allInstrumentations = getDefaultInstrumentationConfigs({ additionalInstrumentations: options.instrumentations, }); @@ -68,10 +65,8 @@ function getMatcher( return matcherCache.get(configHash)!; } - // Free old matchers to prevent memory leaks - for (const [hash, matcher] of matcherCache.entries()) { + for (const hash of matcherCache.keys()) { if (hash !== configHash) { - matcher.free(); matcherCache.delete(hash); } } @@ -83,9 +78,6 @@ function getMatcher( // Cleanup on process exit process.on("exit", () => { - for (const matcher of matcherCache.values()) { - matcher.free(); - } matcherCache.clear(); }); @@ -158,8 +150,6 @@ function codeTransformerLoader( error, ); callback(null, code, inputSourceMap); - } finally { - transformer.free(); } } diff --git a/js/src/auto-instrumentations/bundler/webpack.ts b/js/src/auto-instrumentations/bundler/webpack.ts index 8c3ed1c7d..2f3d9e0b1 100644 --- a/js/src/auto-instrumentations/bundler/webpack.ts +++ b/js/src/auto-instrumentations/bundler/webpack.ts @@ -4,6 +4,7 @@ import { unplugin, type LegacyBundlerPluginOptions, } from "./plugin"; +export type { InstrumentationConfig } from "../orchestrion-js"; export function braintrustWebpackPlugin( options: BundlerPluginOptions = {}, diff --git a/js/src/auto-instrumentations/configs/ai-sdk.ts b/js/src/auto-instrumentations/configs/ai-sdk.ts index 4db293fed..09a43bb4d 100644 --- a/js/src/auto-instrumentations/configs/ai-sdk.ts +++ b/js/src/auto-instrumentations/configs/ai-sdk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { aiSDKChannels } from "../../instrumentation/plugins/ai-sdk-channels"; /** diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts index ad179de34..20dd68fe3 100644 --- a/js/src/auto-instrumentations/configs/all.ts +++ b/js/src/auto-instrumentations/configs/all.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { isInstrumentationIntegrationDisabled, readDisabledInstrumentationEnvConfig, diff --git a/js/src/auto-instrumentations/configs/anthropic.ts b/js/src/auto-instrumentations/configs/anthropic.ts index fad209141..61d4c48db 100644 --- a/js/src/auto-instrumentations/configs/anthropic.ts +++ b/js/src/auto-instrumentations/configs/anthropic.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { anthropicChannels } from "../../instrumentation/plugins/anthropic-channels"; /** diff --git a/js/src/auto-instrumentations/configs/claude-agent-sdk.ts b/js/src/auto-instrumentations/configs/claude-agent-sdk.ts index ac375f464..1cf2c6537 100644 --- a/js/src/auto-instrumentations/configs/claude-agent-sdk.ts +++ b/js/src/auto-instrumentations/configs/claude-agent-sdk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { claudeAgentSDKChannels } from "../../instrumentation/plugins/claude-agent-sdk-channels"; /** diff --git a/js/src/auto-instrumentations/configs/cohere.ts b/js/src/auto-instrumentations/configs/cohere.ts index 8810fa8c6..106980884 100644 --- a/js/src/auto-instrumentations/configs/cohere.ts +++ b/js/src/auto-instrumentations/configs/cohere.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { cohereChannels } from "../../instrumentation/plugins/cohere-channels"; export const cohereConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/cursor-sdk.ts b/js/src/auto-instrumentations/configs/cursor-sdk.ts index 4aa8ed631..ecb845b4b 100644 --- a/js/src/auto-instrumentations/configs/cursor-sdk.ts +++ b/js/src/auto-instrumentations/configs/cursor-sdk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { cursorSDKChannels } from "../../instrumentation/plugins/cursor-sdk-channels"; const cursorSDKVersionRange = ">=1.0.7 <2.0.0"; diff --git a/js/src/auto-instrumentations/configs/flue.ts b/js/src/auto-instrumentations/configs/flue.ts index db2792c06..4998907bd 100644 --- a/js/src/auto-instrumentations/configs/flue.ts +++ b/js/src/auto-instrumentations/configs/flue.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { flueChannels } from "../../instrumentation/plugins/flue-channels"; const flueVersionRange = ">=0.8.0"; diff --git a/js/src/auto-instrumentations/configs/genkit.ts b/js/src/auto-instrumentations/configs/genkit.ts index 2ba2a111f..349635b01 100644 --- a/js/src/auto-instrumentations/configs/genkit.ts +++ b/js/src/auto-instrumentations/configs/genkit.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { genkitChannels, genkitCoreChannels, diff --git a/js/src/auto-instrumentations/configs/github-copilot.ts b/js/src/auto-instrumentations/configs/github-copilot.ts index 497419f12..901f9e682 100644 --- a/js/src/auto-instrumentations/configs/github-copilot.ts +++ b/js/src/auto-instrumentations/configs/github-copilot.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { gitHubCopilotChannels } from "../../instrumentation/plugins/github-copilot-channels"; export const gitHubCopilotConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/google-adk.ts b/js/src/auto-instrumentations/configs/google-adk.ts index 55b9ce662..aeb72c44b 100644 --- a/js/src/auto-instrumentations/configs/google-adk.ts +++ b/js/src/auto-instrumentations/configs/google-adk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { googleADKChannels } from "../../instrumentation/plugins/google-adk-channels"; const googleADKVersionRange = ">=0.1.0"; diff --git a/js/src/auto-instrumentations/configs/google-genai.ts b/js/src/auto-instrumentations/configs/google-genai.ts index 0efea4b25..16edd1d5c 100644 --- a/js/src/auto-instrumentations/configs/google-genai.ts +++ b/js/src/auto-instrumentations/configs/google-genai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { googleGenAIChannels } from "../../instrumentation/plugins/google-genai-channels"; /** diff --git a/js/src/auto-instrumentations/configs/groq.ts b/js/src/auto-instrumentations/configs/groq.ts index 9528303de..864b2a166 100644 --- a/js/src/auto-instrumentations/configs/groq.ts +++ b/js/src/auto-instrumentations/configs/groq.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { groqChannels } from "../../instrumentation/plugins/groq-channels"; export const groqConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/huggingface.ts b/js/src/auto-instrumentations/configs/huggingface.ts index 04ed77912..f4c9bec06 100644 --- a/js/src/auto-instrumentations/configs/huggingface.ts +++ b/js/src/auto-instrumentations/configs/huggingface.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { huggingFaceChannels } from "../../instrumentation/plugins/huggingface-channels"; export const huggingFaceConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/langchain.ts b/js/src/auto-instrumentations/configs/langchain.ts index eb1a2f776..e8108ed02 100644 --- a/js/src/auto-instrumentations/configs/langchain.ts +++ b/js/src/auto-instrumentations/configs/langchain.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { langChainChannels } from "../../instrumentation/plugins/langchain-channels"; const langChainCoreVersionRange = ">=0.3.42"; diff --git a/js/src/auto-instrumentations/configs/mistral.ts b/js/src/auto-instrumentations/configs/mistral.ts index a16f40832..e61fc24be 100644 --- a/js/src/auto-instrumentations/configs/mistral.ts +++ b/js/src/auto-instrumentations/configs/mistral.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { mistralChannels } from "../../instrumentation/plugins/mistral-channels"; export const mistralConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/openai-agents.ts b/js/src/auto-instrumentations/configs/openai-agents.ts index 6c9bf1f7e..3647edbc5 100644 --- a/js/src/auto-instrumentations/configs/openai-agents.ts +++ b/js/src/auto-instrumentations/configs/openai-agents.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { openAIAgentsCoreChannels } from "../../instrumentation/plugins/openai-agents-channels"; const lifecycleMethods = [ diff --git a/js/src/auto-instrumentations/configs/openai-codex.ts b/js/src/auto-instrumentations/configs/openai-codex.ts index adf9046a3..40ee60784 100644 --- a/js/src/auto-instrumentations/configs/openai-codex.ts +++ b/js/src/auto-instrumentations/configs/openai-codex.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { openAICodexChannels } from "../../instrumentation/plugins/openai-codex-channels"; const openAICodexVersionRange = ">=0.128.0 <1.0.0"; diff --git a/js/src/auto-instrumentations/configs/openai.ts b/js/src/auto-instrumentations/configs/openai.ts index 7db437de4..28d85b7f6 100644 --- a/js/src/auto-instrumentations/configs/openai.ts +++ b/js/src/auto-instrumentations/configs/openai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { openAIChannels } from "../../instrumentation/plugins/openai-channels"; /** diff --git a/js/src/auto-instrumentations/configs/openrouter-agent.ts b/js/src/auto-instrumentations/configs/openrouter-agent.ts index f27411cb0..f9f6191e1 100644 --- a/js/src/auto-instrumentations/configs/openrouter-agent.ts +++ b/js/src/auto-instrumentations/configs/openrouter-agent.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { openRouterAgentChannels } from "../../instrumentation/plugins/openrouter-agent-channels"; export const openRouterAgentConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/openrouter.ts b/js/src/auto-instrumentations/configs/openrouter.ts index 18ac7ca66..15bf47919 100644 --- a/js/src/auto-instrumentations/configs/openrouter.ts +++ b/js/src/auto-instrumentations/configs/openrouter.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { openRouterChannels } from "../../instrumentation/plugins/openrouter-channels"; export const openRouterConfigs: InstrumentationConfig[] = [ diff --git a/js/src/auto-instrumentations/configs/pi-coding-agent.ts b/js/src/auto-instrumentations/configs/pi-coding-agent.ts index 488c9cb1b..660143369 100644 --- a/js/src/auto-instrumentations/configs/pi-coding-agent.ts +++ b/js/src/auto-instrumentations/configs/pi-coding-agent.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +import type { InstrumentationConfig } from "../orchestrion-js"; import { piCodingAgentChannels } from "../../instrumentation/plugins/pi-coding-agent-channels"; const piCodingAgentVersionRange = ">=0.79.0 <0.80.0"; diff --git a/js/src/auto-instrumentations/index.ts b/js/src/auto-instrumentations/index.ts index e316649c0..59259d690 100644 --- a/js/src/auto-instrumentations/index.ts +++ b/js/src/auto-instrumentations/index.ts @@ -48,7 +48,5 @@ export { gitHubCopilotConfigs } from "./configs/github-copilot"; export { langchainConfigs } from "./configs/langchain"; export { piCodingAgentConfigs } from "./configs/pi-coding-agent"; -// Re-export orchestrion configuration types -// Note: ModuleMetadata and FunctionQuery are properties of InstrumentationConfig, -// not separate exports from @apm-js-collab/code-transformer -export type { InstrumentationConfig } from "@apm-js-collab/code-transformer"; +// Re-export orchestrion configuration types from the internal fork. +export type { InstrumentationConfig } from "./orchestrion-js"; diff --git a/js/src/auto-instrumentations/loader/cjs-patch.ts b/js/src/auto-instrumentations/loader/cjs-patch.ts index c882a03d4..670a6a5b1 100644 --- a/js/src/auto-instrumentations/loader/cjs-patch.ts +++ b/js/src/auto-instrumentations/loader/cjs-patch.ts @@ -3,10 +3,7 @@ * Patches Module.prototype._compile to transform CommonJS modules at load time. */ -import { - create, - type InstrumentationConfig, -} from "@apm-js-collab/code-transformer"; +import { create, type InstrumentationConfig } from "../orchestrion-js"; import * as NodeModule from "node:module"; import { sep } from "node:path"; import moduleDetailsFromPath from "module-details-from-path"; @@ -81,8 +78,6 @@ export class ModulePatch { args[0] = transformedCode?.code; } catch (error) { console.warn(`Error transforming module ${filename}:`, error); - } finally { - transformer.free(); } } } diff --git a/js/src/auto-instrumentations/loader/esm-hook.mts b/js/src/auto-instrumentations/loader/esm-hook.mts index a8f199add..09bf03139 100644 --- a/js/src/auto-instrumentations/loader/esm-hook.mts +++ b/js/src/auto-instrumentations/loader/esm-hook.mts @@ -6,10 +6,7 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { extname, sep } from "node:path"; -import { - create, - type InstrumentationConfig, -} from "@apm-js-collab/code-transformer"; +import { create, type InstrumentationConfig } from "../orchestrion-js"; import moduleDetailsFromPath from "module-details-from-path"; import { getPackageName, getPackageVersion } from "./get-package-version.js"; import { @@ -159,8 +156,6 @@ export async function load(url: string, context: any, nextLoad: Function) { result.shortCircuit = true; } catch (err) { console.warn(`Error transforming module ${url}:`, err); - } finally { - transformer.free(); } } diff --git a/js/src/auto-instrumentations/loader/special-case-patches.ts b/js/src/auto-instrumentations/loader/special-case-patches.ts index a4b45fc9b..502b0af51 100644 --- a/js/src/auto-instrumentations/loader/special-case-patches.ts +++ b/js/src/auto-instrumentations/loader/special-case-patches.ts @@ -5,7 +5,7 @@ * * Every entry in this file represents a target SDK that doesn't expose a * stable extension point we can hook through diagnostics_channel + the - * `@apm-js-collab/code-transformer` matcher. New integrations should + * internal Orchestrion matcher. New integrations should * **prefer the standard channel-handler / `BasePlugin` pattern** used by * every other integration in `js/src/instrumentation/plugins/*-plugin.ts`. * Only add an entry here when the target SDK gives us no other option (e.g. diff --git a/js/src/auto-instrumentations/orchestrion-js/index.ts b/js/src/auto-instrumentations/orchestrion-js/index.ts new file mode 100644 index 000000000..831fb0e2f --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/index.ts @@ -0,0 +1,19 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +import { InstrumentationMatcher } from "./matcher"; +import type { InstrumentationConfig } from "./types"; + +/** + * Creates a new InstrumentationMatcher from the given instrumentation configs. + */ +export function create( + configs: InstrumentationConfig[], + dcModule?: string | null, +): InstrumentationMatcher { + return new InstrumentationMatcher(configs, dcModule); +} + +export type { InstrumentationConfig } from "./types"; diff --git a/js/src/auto-instrumentations/orchestrion-js/matcher.ts b/js/src/auto-instrumentations/orchestrion-js/matcher.ts new file mode 100644 index 000000000..cbb755f71 --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/matcher.ts @@ -0,0 +1,62 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +import semifies from "semifies"; +import { Transformer } from "./transformer"; +import type { InstrumentationConfig } from "./types"; + +/** + * Matches instrumentation configs against a given module/file/version triple and + * returns a cached Transformer for matching configs. + */ +export class InstrumentationMatcher { + private configs: InstrumentationConfig[] = []; + private dcModule: string; + private transformers: Record = {}; + + constructor(configs: InstrumentationConfig[], dcModule?: string | null) { + this.configs = configs; + this.dcModule = dcModule || "diagnostics_channel"; + } + + /** + * Returns a Transformer for the given module/file/version, or undefined if no + * registered config matches. + */ + getTransformer( + moduleName: string, + version: string, + filePath: string, + ): Transformer | undefined { + filePath = filePath.replace(/\\/g, "/"); + + const id = `${moduleName}/${filePath}@${version}`; + + if (this.transformers[id]) { + return this.transformers[id]; + } + + const configs = this.configs.filter( + ({ module: mod }) => + mod.name === moduleName && + mod.filePath === filePath && + semifies(version, mod.versionRange), + ); + + if (configs.length === 0) { + return undefined; + } + + this.transformers[id] = new Transformer( + moduleName, + version, + filePath, + configs, + this.dcModule, + ); + + return this.transformers[id]; + } +} diff --git a/js/src/auto-instrumentations/orchestrion-js/transformer.ts b/js/src/auto-instrumentations/orchestrion-js/transformer.ts new file mode 100644 index 000000000..54fc0a6f5 --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/transformer.ts @@ -0,0 +1,282 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +import esquery from "esquery"; +import { generate } from "astring"; +import { parse } from "meriyah"; +import { SourceMapGenerator } from "source-map"; +import { transforms, type TransformState } from "./transforms"; +import type { + FunctionKind, + FunctionQuery, + InstrumentationConfig, + ModuleType, + TransformOutput, +} from "./types"; + +type AnyNode = any; +type ExportAliases = Record; +type TraceOperator = "traceCallback" | "tracePromise" | "traceSync"; + +/** + * Applies instrumentation configs to JavaScript source by parsing it into an + * AST, locating target functions, injecting diagnostics_channel tracing + * wrappers, and regenerating the source. + */ +export class Transformer { + private moduleName: string; + private version: string; + private filePath: string; + private configs: InstrumentationConfig[] = []; + private dcModule: string; + + constructor( + moduleName: string, + version: string, + filePath: string, + configs: InstrumentationConfig[], + dcModule: string, + ) { + this.moduleName = moduleName; + this.version = version; + this.filePath = filePath; + this.configs = configs; + this.dcModule = dcModule; + } + + /** + * Instruments `code` by injecting diagnostics_channel tracing around the + * target functions defined by this transformer's configs. + */ + transform(code: string | Buffer, moduleType: ModuleType): TransformOutput { + if (Buffer.isBuffer(code)) { + code = code.toString(); + } + if (!code) { + return { code }; + } + + let ast: AnyNode | undefined; + let aliases: ExportAliases = {}; + let injectionCount = 0; + + for (const config of this.configs) { + const { astQuery, functionQuery } = config; + + if (!ast) { + const options = { + loc: true, + ranges: true, + raw: true, + module: moduleType === "esm", + }; + + try { + ast = parse(code, options as any); + } catch { + ast = parse(code, { ...options, module: !options.module } as any); + } + + if (moduleType === "esm") { + aliases = this.collectExportAliases(ast); + } + } + + const resolvedFunctionQuery = this.resolveExportAlias( + functionQuery, + aliases, + ); + const query = astQuery || this.fromFunctionQuery(resolvedFunctionQuery); + const state: TransformState = { + ...config, + dcModule: this.dcModule, + moduleType, + moduleVersion: this.version, + functionQuery: resolvedFunctionQuery, + operator: this.getOperator(resolvedFunctionQuery.kind), + }; + + esquery.traverse(ast, esquery.parse(query), (...args: any[]) => { + injectionCount++; + this.visit(state, ...args); + }); + } + + if (injectionCount === 0 && this.configs.length > 0) { + const names = this.configs.map(({ astQuery, functionQuery }) => { + const resolvedQuery = this.resolveExportAlias(functionQuery, aliases); + const queryName = (q: any) => + q.methodName || + q.privateMethodName || + q.functionName || + q.propertyName || + astQuery || + "unknown"; + const originalName = queryName(functionQuery); + const originalAlias = + (functionQuery as any).className || + (functionQuery as any).functionName; + const resolvedAlias = + (resolvedQuery as any).className || + (resolvedQuery as any).functionName; + if (originalAlias && originalAlias !== resolvedAlias) { + return `${originalAlias} (local name: ${resolvedAlias})`; + } + return originalName; + }); + throw new Error( + `Failed to find injection points for: ${JSON.stringify(names)}`, + ); + } + + if (ast) { + const file = `${this.moduleName}/${this.filePath}`; + const sourceMap = new SourceMapGenerator({ file }); + const transformedCode = generate(ast, { sourceMap }); + const map = sourceMap.toString(); + + return { code: transformedCode, map }; + } + + return { code }; + } + + private visit(state: TransformState, ...args: any[]): void { + const transform = transforms[state.operator]; + const { index = 0 } = state.functionQuery as any; + const [node] = args; + const type = node.init?.type || node.type; + + // Class nodes are visited for traceInstanceMethod, but index matching only + // counts function nodes. + if (type !== "ClassDeclaration" && type !== "ClassExpression") { + if (node.type === "VariableDeclarator") { + return; + } + + state.functionIndex = + state.functionIndex === undefined ? 0 : state.functionIndex + 1; + + if (index !== null && index !== state.functionIndex) { + return; + } + } + + (transform as (...args: any[]) => void)(state, ...args); + } + + private getOperator(kind: FunctionKind): TraceOperator { + switch (kind) { + case "Async": + return "tracePromise"; + case "Callback": + return "traceCallback"; + case "Sync": + return "traceSync"; + } + } + + private collectExportAliases(ast: AnyNode): ExportAliases { + const aliases: ExportAliases = {}; + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration" && !node.source) { + for (const spec of node.specifiers) { + if (spec.exported && spec.local) { + const exportedName = spec.exported.name ?? spec.exported.value; + const localName = spec.local.name ?? spec.local.value; + if (exportedName && localName) { + aliases[exportedName] = localName; + } + } + } + } + } + return aliases; + } + + private resolveExportAlias( + functionQuery: FunctionQuery, + aliases: ExportAliases, + ): FunctionQuery { + if (!("isExportAlias" in functionQuery) || !functionQuery.isExportAlias) { + return functionQuery; + } + if ("className" in functionQuery && aliases[functionQuery.className]) { + return { + ...functionQuery, + className: aliases[functionQuery.className], + }; + } + if ( + "functionName" in functionQuery && + aliases[functionQuery.functionName] + ) { + return { + ...functionQuery, + functionName: aliases[functionQuery.functionName], + }; + } + return functionQuery; + } + + private functionQueryLabel(functionQuery: FunctionQuery): string { + if ("methodName" in functionQuery) { + return functionQuery.methodName; + } + if ("privateMethodName" in functionQuery) { + return functionQuery.privateMethodName; + } + if ("functionName" in functionQuery) { + return functionQuery.functionName; + } + if ("propertyName" in functionQuery) { + return functionQuery.propertyName; + } + return "unknown"; + } + + private fromFunctionQuery(functionQuery: FunctionQuery): string { + const queries: string[] = []; + + if ("className" in functionQuery) { + const { className } = functionQuery; + const methodName = this.functionQueryLabel(functionQuery); + const keyType = + "privateMethodName" in functionQuery + ? "PrivateIdentifier" + : "Identifier"; + queries.push( + `[id.name="${className}"]`, + `[id.name="${className}"] > ClassExpression`, + `[id.name="${className}"] > ClassBody > [key.name="${methodName}"][key.type=${keyType}] > [async]`, + `[id.name="${className}"] > ClassExpression > ClassBody > [key.name="${methodName}"][key.type=${keyType}] > [async]`, + ); + } else if ("methodName" in functionQuery) { + const { methodName } = functionQuery; + queries.push( + `ClassBody > [key.name="${methodName}"][key.type=Identifier] > [async]`, + `Property[key.name="${methodName}"][key.type=Identifier] > [async]`, + ); + } + + if ("functionName" in functionQuery) { + const { functionName } = functionQuery; + queries.push(`FunctionDeclaration[id.name="${functionName}"][async]`); + } + + if ("objectName" in functionQuery) { + const { objectName, propertyName } = functionQuery; + const objectSelector = + objectName === "this" + ? "left.object.type=ThisExpression" + : `left.object.name="${objectName}"`; + queries.push( + `AssignmentExpression[${objectSelector}][left.property.name="${propertyName}"] > [async]`, + ); + } + + return queries.join(", "); + } +} diff --git a/js/src/auto-instrumentations/orchestrion-js/transforms.ts b/js/src/auto-instrumentations/orchestrion-js/transforms.ts new file mode 100644 index 000000000..15d4bfd1b --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/transforms.ts @@ -0,0 +1,447 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +import esquery from "esquery"; +import { parse } from "meriyah"; +import type { FunctionQuery, InstrumentationConfig, ModuleType } from "./types"; + +type AnyNode = any; +type TransformFn = ( + state: TransformState, + node: AnyNode, + parent: AnyNode, + ancestry: AnyNode[], +) => void; +type TraceOperator = "traceCallback" | "tracePromise" | "traceSync"; + +export interface TransformState extends InstrumentationConfig { + dcModule: string; + moduleType: ModuleType; + moduleVersion: string; + functionQuery: FunctionQuery; + operator: TraceOperator; + functionIndex?: number; +} + +const tracingChannelPredicate = (node: AnyNode): boolean => + node.declarations?.[0]?.id?.properties?.[0]?.value?.name === + "tr_ch_apm_tracingChannel"; + +const CHANNEL_REGEX = /[^\w]/g; + +function formatChannelVariable(channelName: string): string { + return `tr_ch_apm$${channelName.replace(CHANNEL_REGEX, "_")}`; +} + +export const transforms: Record = { + tracingChannelImport({ dcModule, moduleType }, node) { + if (node.body.some(tracingChannelPredicate)) { + return; + } + + const options = { module: moduleType === "esm" }; + const index = node.body.findIndex( + (child: AnyNode) => child.directive === "use strict", + ); + const dc = + moduleType === "esm" + ? `import tr_ch_apm_dc from "${dcModule}"` + : `const tr_ch_apm_dc = ${"require"}("${dcModule}")`; + const tracingChannel = + "const { tracingChannel: tr_ch_apm_tracingChannel } = tr_ch_apm_dc"; + const hasSubscribers = `const tr_ch_apm_hasSubscribers = ch => ch.start.hasSubscribers + || ch.end.hasSubscribers + || ch.asyncStart.hasSubscribers + || ch.asyncEnd.hasSubscribers + || ch.error.hasSubscribers`; + + node.body.splice( + index + 1, + 0, + parse(dc, options as any).body[0], + parse(tracingChannel, options as any).body[0], + parse(hasSubscribers, options as any).body[0], + ); + }, + + tracingChannelDeclaration(state, node) { + const { + channelName, + module: { name }, + } = state; + const channelVariable = formatChannelVariable(channelName); + + if ( + node.body.some( + (child: AnyNode) => + child.declarations?.[0]?.id?.name === channelVariable, + ) + ) { + return; + } + + transforms.tracingChannelImport(state, node, null, []); + + const index = node.body.findIndex(tracingChannelPredicate); + const code = ` + const ${channelVariable} = tr_ch_apm_tracingChannel("orchestrion:${name}:${channelName}") + `; + + node.body.splice(index + 1, 0, parse(code).body[0]); + }, + + traceCallback: traceAny, + tracePromise: traceAny, + traceSync: traceAny, +}; + +function traceAny( + state: TransformState, + node: AnyNode, + _parent: AnyNode, + ancestry: AnyNode[], +): void { + const program = ancestry[ancestry.length - 1]; + + if (node.type === "ClassDeclaration" || node.type === "ClassExpression") { + traceInstanceMethod(state, node, program); + } else { + traceFunction(state, node, program); + } +} + +function traceFunction( + state: TransformState, + node: AnyNode, + program: AnyNode, +): void { + transforms.tracingChannelDeclaration(state, program, null, []); + + const { functionQuery } = state; + const methodName = + "methodName" in functionQuery ? functionQuery.methodName : undefined; + const privateMethodName = + "privateMethodName" in functionQuery + ? functionQuery.privateMethodName + : undefined; + const functionName = + "functionName" in functionQuery ? functionQuery.functionName : undefined; + const isConstructor = + methodName === "constructor" || + (!methodName && !privateMethodName && !functionName); + const type = isConstructor ? "ArrowFunctionExpression" : "FunctionExpression"; + + node.body = wrap( + state, + { + type, + params: node.params, + body: node.body, + async: node.async, + expression: false, + generator: node.generator, + }, + program, + ); + + node.generator = false; + node.async = false; + + wrapSuper(node); +} + +function traceInstanceMethod( + state: TransformState, + node: AnyNode, + program: AnyNode, +): void { + const { functionQuery, operator } = state; + const { methodName } = functionQuery as any; + + if (!methodName) { + return; + } + + const classBody = node.body; + + if (classBody.body.some(({ key }: AnyNode) => key.name === methodName)) { + return; + } + + let ctor = classBody.body.find(({ kind }: AnyNode) => kind === "constructor"); + + transforms.tracingChannelDeclaration(state, program, null, []); + + if (!ctor) { + ctor = ( + parse( + node.superClass + ? "class A extends Object { constructor (...args) { super(...args) } }" + : "class A { constructor () {} }", + ) as any + ).body[0].body.body[0]; + + classBody.body.unshift(ctor); + } + + const ctorBody = ( + parse(` + const __apm$${methodName} = this["${methodName}"] + this["${methodName}"] = function () {} + `) as any + ).body; + + const fn = ctorBody[1].expression.right; + + fn.async = operator === "tracePromise"; + fn.body = wrap( + state, + { type: "Identifier", name: `__apm$${methodName}` }, + program, + ); + + wrapSuper(fn); + + ctor.value.body.body.push(...ctorBody); +} + +function wrap( + state: TransformState, + node: AnyNode, + program?: AnyNode, +): AnyNode { + const { operator, moduleVersion } = state; + + const wrapper = + operator === "traceCallback" + ? wrapCallback(state) + : operator === "tracePromise" + ? wrapPromise(state) + : wrapSync(state); + + const block = wrapper.body[0].body; + const common = parse( + node.type === "ArrowFunctionExpression" + ? ` + const __apm$ctx = { + arguments, + moduleVersion: ${JSON.stringify(moduleVersion)} + }; + const __apm$traced = () => { + const __apm$wrapped = () => {}; + return __apm$wrapped(...arguments); + }; + ` + : ` + const __apm$ctx = { + arguments, + self: this, + moduleVersion: ${JSON.stringify(moduleVersion)} + }; + const __apm$traced = () => { + const __apm$wrapped = () => {}; + return __apm$wrapped.apply(this, arguments); + }; + `, + ).body; + + block.body.unshift(...common); + + esquery.query(block, "[id.name=__apm$wrapped]")[0].init = node; + + return block; +} + +function wrapSuper(node: AnyNode): void { + const members = new Set(); + + esquery.traverse( + node.body, + esquery.parse("[object.type=Super]"), + (node: AnyNode, parent: AnyNode) => { + const { name } = node.property; + + let child: AnyNode; + + if (parent.callee) { + const { expression } = ( + parse(`__apm$super['${name}'].call(this)`) as any + ).body[0]; + + parent.callee = child = expression.callee; + parent.arguments.unshift(...expression.arguments); + } else { + parent.expression = child = parse(`__apm$super['${name}']`).body[0]; + } + + child.computed = parent.callee.computed; + child.optional = parent.callee.optional; + + members.add(name); + }, + ); + + for (const name of members) { + const member = ( + parse(` + class Wrapper { + wrapper () { + __apm$super['${name}'] = super['${name}'] + } + } + `) as any + ).body[0].body.body[0].value.body.body[0]; + + node.body.body.unshift(member); + } + + if (members.size > 0) { + node.body.body.unshift(parse("const __apm$super = {}").body[0]); + } +} + +function wrapCallback(state: TransformState): AnyNode { + const { + channelName, + functionQuery: { callbackIndex = -1 }, + } = state; + const channelVariable = formatChannelVariable(channelName); + + return parse(` + function wrapper () { + const __apm$cb = Array.prototype.at.call(arguments, ${callbackIndex}); + + if (!${channelVariable}.start.hasSubscribers) return __apm$traced(); + + function __apm$wrappedCb(err, res) { + if (err) { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + } else { + __apm$ctx.result = res; + } + + ${channelVariable}.asyncStart.runStores(__apm$ctx, () => { + try { + if (__apm$cb) { + return __apm$cb.apply(this, arguments); + } + } finally { + ${channelVariable}.asyncEnd.publish(__apm$ctx); + } + }); + } + + if (typeof __apm$cb !== 'function') { + return __apm$traced(); + } + Array.prototype.splice.call(arguments, ${callbackIndex}, 1, __apm$wrappedCb); + + return ${channelVariable}.start.runStores(__apm$ctx, () => { + try { + return __apm$traced(); + } catch (err) { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + throw err; + } finally { + __apm$ctx.self ??= this; + ${channelVariable}.end.publish(__apm$ctx); + } + }); + } + `); +} + +function wrapPromise(state: TransformState): AnyNode { + const { channelName } = state; + const channelVariable = formatChannelVariable(channelName); + + return parse(` + function wrapper () { + if (!tr_ch_apm_hasSubscribers(${channelVariable})) return __apm$traced(); + + return ${channelVariable}.start.runStores(__apm$ctx, () => { + try { + let promise = __apm$traced(); + if (typeof promise?.then !== 'function') { + __apm$ctx.result = promise; + return promise; + } + // Mirror Node.js core diagnostics_channel behaviour: for native Promise + // instances, chain normally (safe since there is no subclass API to + // preserve). For Promise subclasses and other thenables, side-chain the + // callbacks for event publishing and return the original so that any + // subclass-specific methods (e.g. APIPromise.withResponse()) remain + // accessible to the caller. + if (promise instanceof Promise && promise.constructor === Promise) { + return promise.then( + result => { + __apm$ctx.result = result; + ${channelVariable}.asyncStart.publish(__apm$ctx); + ${channelVariable}.asyncEnd.publish(__apm$ctx); + return result; + }, + err => { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + ${channelVariable}.asyncStart.publish(__apm$ctx); + ${channelVariable}.asyncEnd.publish(__apm$ctx); + throw err; + } + ); + } + promise.then( + result => { + __apm$ctx.result = result; + ${channelVariable}.asyncStart.publish(__apm$ctx); + ${channelVariable}.asyncEnd.publish(__apm$ctx); + }, + err => { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + ${channelVariable}.asyncStart.publish(__apm$ctx); + ${channelVariable}.asyncEnd.publish(__apm$ctx); + } + ); + return promise; + } catch (err) { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + throw err; + } finally { + __apm$ctx.self ??= this; + ${channelVariable}.end.publish(__apm$ctx); + } + }); + } + `); +} + +function wrapSync(state: TransformState): AnyNode { + const { channelName } = state; + const channelVariable = formatChannelVariable(channelName); + + return parse(` + function wrapper () { + if (!tr_ch_apm_hasSubscribers(${channelVariable})) return __apm$traced(); + + return ${channelVariable}.start.runStores(__apm$ctx, () => { + try { + const result = __apm$traced(); + __apm$ctx.result = result; + return result; + } catch (err) { + __apm$ctx.error = err; + ${channelVariable}.error.publish(__apm$ctx); + throw err; + } finally { + __apm$ctx.self ??= this; + ${channelVariable}.end.publish(__apm$ctx); + } + }); + } + `); +} diff --git a/js/src/auto-instrumentations/orchestrion-js/types.ts b/js/src/auto-instrumentations/orchestrion-js/types.ts new file mode 100644 index 000000000..67237c18e --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/types.ts @@ -0,0 +1,106 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +/** + * Output of a transformation operation. + */ +export interface TransformOutput { + /** + * The transformed JavaScript code. + */ + code: string; + + /** + * The sourcemap for the transformation, if generated. + */ + map?: string; +} + +/** + * The kind of function. + */ +export type FunctionKind = "Sync" | "Async" | "Callback"; + +interface FunctionQueryBase { + kind: FunctionKind; + index?: number | null; + callbackIndex?: number; +} + +/** + * Describes which function to instrument. + */ +export type FunctionQuery = + | (FunctionQueryBase & { + className: string; + methodName: string; + isExportAlias?: boolean; + }) + | (FunctionQueryBase & { + className: string; + privateMethodName: string; + isExportAlias?: boolean; + }) + | (FunctionQueryBase & { methodName: string }) + | (FunctionQueryBase & { + functionName: string; + isExportAlias?: boolean; + }) + | (FunctionQueryBase & { + objectName: string; + propertyName: string; + }) + | FunctionQueryBase; + +/** + * Configuration for injecting instrumentation code. + */ +export interface InstrumentationConfig { + /** + * The name of the diagnostics channel to publish to. + */ + channelName: string; + + /** + * The module matcher to identify the module and file to instrument. + */ + module: ModuleMatcher; + + /** + * The function query to identify the function to instrument. + */ + functionQuery: FunctionQuery; + + /** + * Optional raw esquery selector. When provided, this selector is used instead + * of deriving one from functionQuery. + */ + astQuery?: string; +} + +/** + * Describes the module and file path you would like to match. + */ +export interface ModuleMatcher { + /** + * The name of the module you want to match. + */ + name: string; + + /** + * The semver range that you want to match. + */ + versionRange: string; + + /** + * The path of the file you want to match from the module root. + */ + filePath: string; +} + +/** + * The type of module being passed: ESM, CJS, or unknown. + */ +export type ModuleType = "esm" | "cjs" | "unknown"; diff --git a/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts b/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts new file mode 100644 index 000000000..2d0407a33 --- /dev/null +++ b/js/src/auto-instrumentations/orchestrion-js/vendor-deps.d.ts @@ -0,0 +1,22 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +declare module "esquery" { + const esquery: { + parse(selector: string): unknown; + traverse( + ast: unknown, + selector: unknown, + visitor: (node: any, parent: any, ancestry: any[]) => void, + ): void; + query(ast: unknown, selector: string): any[]; + }; + export = esquery; +} + +declare module "semifies" { + function semifies(version: string, range: string): boolean; + export = semifies; +} diff --git a/js/src/instrumentation/README.md b/js/src/instrumentation/README.md index 34179ad8d..8ed64dfa6 100644 --- a/js/src/instrumentation/README.md +++ b/js/src/instrumentation/README.md @@ -20,7 +20,7 @@ This guide explains how to write plugins that consume diagnostics channel events ### What is Orchestrion? -[Orchestrion](https://github.com/apm-js-collab/orchestrion-js) is a code transformation library that automatically instruments JavaScript/TypeScript code at bundle-time or load-time. It injects calls to Node.js's [diagnostics_channel API](https://nodejs.org/api/diagnostics_channel.html) to enable observability without manual code changes that may be enabled or disabled at any time. +[Orchestrion](https://github.com/nodejs/orchestrion-js) is a code transformation library that automatically instruments JavaScript/TypeScript code at bundle-time or load-time. It injects calls to Node.js's [diagnostics_channel API](https://nodejs.org/api/diagnostics_channel.html) to enable observability without manual code changes that may be enabled or disabled at any time. **Key concepts:** @@ -1401,7 +1401,7 @@ asyncEnd: (event) => { ## Additional Resources -- [Orchestrion Documentation](https://github.com/apm-js-collab/orchestrion-js) +- [Orchestrion Documentation](https://github.com/nodejs/orchestrion-js) - [Node.js Diagnostics Channel API](https://nodejs.org/api/diagnostics_channel.html) - [BraintrustPlugin Implementation](./braintrust-plugin.ts) - [Stream Patcher Source](./core/stream-patcher.ts) diff --git a/js/src/instrumentation/core/index.ts b/js/src/instrumentation/core/index.ts index c5afe288a..b958f1864 100644 --- a/js/src/instrumentation/core/index.ts +++ b/js/src/instrumentation/core/index.ts @@ -5,8 +5,8 @@ * InstrumentationBase pattern - core infrastructure lives here, but individual * instrumentations can be separate packages. * - * Note: Orchestrion-specific types (InstrumentationConfig, ModuleMetadata, FunctionQuery) - * should be imported directly from @apm-js-collab/code-transformer. + * Note: auto-instrumentation config types are exposed from the Braintrust + * bundler subpaths, such as `braintrust/vite`. */ export { BasePlugin } from "./plugin"; diff --git a/js/src/instrumentation/index.ts b/js/src/instrumentation/index.ts index d76798ef9..8a0a7a7a5 100644 --- a/js/src/instrumentation/index.ts +++ b/js/src/instrumentation/index.ts @@ -8,8 +8,8 @@ * lives in the core SDK, while individual instrumentation implementations * can be separate packages. * - * For orchestrion-js configuration types (InstrumentationConfig, ModuleMetadata, FunctionQuery), - * import directly from @apm-js-collab/code-transformer. + * For auto-instrumentation config types, import InstrumentationConfig from the + * relevant Braintrust bundler subpath such as `braintrust/vite`. * * @module instrumentation */ diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/README.md b/js/tests/auto-instrumentations/fixtures/orchestrion-js/README.md new file mode 100644 index 000000000..38c47f4a6 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/README.md @@ -0,0 +1,8 @@ +This fixture suite is forked from `@apm-js-collab/code-transformer@0.12.0` +(Orchestrion-JS), tag `code-transformer-v0.12.0`, commit +`07e64727ed8a8eb1fe13bf12a6e96bce924b5055`. + +The copied cases cover the Orchestrion behavior Braintrust still supports in +its internal fork. Upstream cases for removed APIs, including custom transforms, +constructor-only instrumentation, and expression-name queries, are +intentionally omitted. diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/mod.js new file mode 100644 index 000000000..44555f510 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/mod.js @@ -0,0 +1,34 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const assert = require("node:assert"); + +function fetch_simple(url, cb) { + assert.strictEqual(this.this, "this"); + assert.strictEqual(url, "https://example.com"); + assert.strictEqual(cb.length, 2); + const result = cb.apply(this, ["arg1", "arg2"]); + assert.strictEqual(result, "result"); + return "return"; +} + +function fetch_complex( + { url, tuple: [a = "a", b = "b"] }, + cb, + optional = "default", + ...rest +) { + assert.strictEqual(this.this, "this"); + assert.strictEqual(url, "https://example.com"); + assert.strictEqual(a, "a"); + assert.strictEqual(b, "b"); + assert.strictEqual(cb.length, 2); + assert.strictEqual(optional, "default"); + assert.deepStrictEqual(rest, []); + const result = cb.apply(this, ["arg1", "arg2"]); + assert.strictEqual(result, "result"); + return "return"; +} + +module.exports = { fetch_simple, fetch_complex }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/test.js new file mode 100644 index 000000000..6406b99a5 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/arguments_mutation/test.js @@ -0,0 +1,47 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch_simple, fetch_complex } = require("./instrumented.js"); +const assert = require("node:assert"); +const { tracingChannel } = require("node:diagnostics_channel"); + +const handler = { + start(message) { + const originalCb = message.arguments[1]; + const wrappedCb = function (a, b) { + assert.strictEqual(this.this, "this"); + assert.strictEqual(a, "arg1"); + assert.strictEqual(b, "arg2"); + arguments[1] = "arg2_mutated"; + return originalCb.apply(this, arguments); + }; + + message.arguments[1] = wrappedCb; + }, +}; + +tracingChannel("orchestrion:undici:fetch_simple").subscribe(handler); +tracingChannel("orchestrion:undici:fetch.complex").subscribe(handler); + +assert.strictEqual(fetch_simple.length, 2); +assert.strictEqual(fetch_complex.length, 2); + +const cb = function (a, b) { + assert.strictEqual(this.this, "this"); + assert.strictEqual(a, "arg1"); + assert.strictEqual(b, "arg2_mutated"); + return "result"; +}; + +assert.strictEqual( + fetch_simple.apply({ this: "this" }, ["https://example.com", cb]), + "return", +); +assert.strictEqual( + fetch_complex.apply({ this: "this" }, [ + { url: "https://example.com", tuple: [] }, + cb, + ]), + "return", +); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/mod.js new file mode 100644 index 000000000..03b190b9a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/mod.js @@ -0,0 +1,10 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +async function fetch(url) { + return 42; +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/test.js new file mode 100644 index 000000000..781fd1c7d --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/ast_query_cjs/test.js @@ -0,0 +1,18 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch_ast_query"); +(async () => { + const result = await fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/mod.js new file mode 100644 index 000000000..8f2225b57 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/mod.js @@ -0,0 +1,12 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +function fetch(url, callback) { + process.nextTick(() => { + callback(null, 42); + }); +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/test.js new file mode 100644 index 000000000..a51ecbc66 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/callback_cjs/test.js @@ -0,0 +1,23 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch.cb"); +(async () => { + const result = await new Promise((resolve, reject) => { + fetch("https://example.com", (err, val) => { + if (err) reject(err); + else resolve(val); + }); + }); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/mod.js new file mode 100644 index 000000000..e5568cbe9 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/mod.js @@ -0,0 +1,16 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +class UndiciBase { + async fetch(url) { + return 42; + } +} +class Undici extends UndiciBase { + async fetch(url) { + return super.fetch(url); + } +} + +module.exports = Undici; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/test.js new file mode 100644 index 000000000..457789642 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_expression_cjs/test.js @@ -0,0 +1,18 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const Undici = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Undici:fetch"); +(async () => { + const undici = new Undici(); + const result = await undici.fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/mod.js new file mode 100644 index 000000000..e5568cbe9 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/mod.js @@ -0,0 +1,16 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +class UndiciBase { + async fetch(url) { + return 42; + } +} +class Undici extends UndiciBase { + async fetch(url) { + return super.fetch(url); + } +} + +module.exports = Undici; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/test.js new file mode 100644 index 000000000..457789642 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/class_method_cjs/test.js @@ -0,0 +1,18 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const Undici = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Undici:fetch"); +(async () => { + const undici = new Undici(); + const result = await undici.fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/common/preamble.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/common/preamble.js new file mode 100644 index 000000000..3ec3b4abb --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/common/preamble.js @@ -0,0 +1,29 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { tracingChannel } = require("node:diagnostics_channel"); +const assert = require("node:assert"); +function getContext(channelName) { + const channel = tracingChannel(channelName); + const context = {}; + channel.subscribe({ + start(message) { + message.context = context; + context.start = true; + }, + end(message) { + message.context.end = message.result ?? true; + // Handle end message + }, + asyncStart(message) { + message.context.asyncStart = message.result; + // Handle asyncStart message + }, + asyncEnd(message) { + message.context.asyncEnd = message.result; + }, + }); + return context; +} +module.exports = { assert, getContext }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/mod.mjs new file mode 100644 index 000000000..c5cd21cbe --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/mod.mjs @@ -0,0 +1,11 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const J = class { + async fetch(url) { + return 42; + } +}; + +export { J as Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/test.mjs new file mode 100644 index 000000000..8fba82870 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/const_class_export_alias_mjs/test.mjs @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { Undici } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:Undici:fetch"); +const undici = new Undici(); +const result = await undici.fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/mod.js new file mode 100644 index 000000000..c977331ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/mod.js @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +async function fetch(url) { + return 42; +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/test.js new file mode 100644 index 000000000..f39df4014 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_cjs/test.js @@ -0,0 +1,17 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch.decl"); +(async () => { + const result = await fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/mod.mjs new file mode 100644 index 000000000..8e57364a2 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/mod.mjs @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +async function fetch(url) { + return 42; +} + +export { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/test.mjs new file mode 100644 index 000000000..07b7c2d35 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs/test.mjs @@ -0,0 +1,15 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +import { fetch } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:fetch_decl"); +const result = await fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/mod.mjs new file mode 100644 index 000000000..85bc54a6e --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/mod.mjs @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +function fetch(url) { + return Promise.resolve(42); +} + +export { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/test.mjs new file mode 100644 index 000000000..07b7c2d35 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/decl_mjs_mismatched_type/test.mjs @@ -0,0 +1,15 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +import { fetch } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:fetch_decl"); +const result = await fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/mod.mjs new file mode 100644 index 000000000..7936f409b --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/mod.mjs @@ -0,0 +1,11 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +class J { + async fetch(url) { + return 42; + } +} + +export { J as Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/test.mjs new file mode 100644 index 000000000..8fba82870 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_class_mjs/test.mjs @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { Undici } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:Undici:fetch"); +const undici = new Undici(); +const result = await undici.fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/mod.mjs new file mode 100644 index 000000000..bb4ce8e74 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/mod.mjs @@ -0,0 +1,9 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +async function f(url) { + return 42; +} + +export { f as fetchAliased }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/test.mjs new file mode 100644 index 000000000..77f065a29 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/export_alias_mjs/test.mjs @@ -0,0 +1,15 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { fetchAliased } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:fetch_alias"); +const result = await fetchAliased("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/mod.js new file mode 100644 index 000000000..f38592a74 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/mod.js @@ -0,0 +1,18 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const Server = (() => { + class Server { + constructor() { + this.id = 1; + } + + register() { + return 1; + } + } + return Server; +})(); + +exports.Server = Server; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/test.js new file mode 100644 index 000000000..939cd6f1f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/iife_nested_class/test.js @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const { Server } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:register"); +(async () => { + const server = new Server(); + const result = server.register(); + assert.strictEqual(result, 1); + assert.deepStrictEqual(context, { + start: true, + end: 1, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/mod.js new file mode 100644 index 000000000..cdd343313 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/mod.js @@ -0,0 +1,49 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +{ + class Undici { + async fetch(url) { + return 0; + } + } + + exports.Undici0 = Undici; +} +{ + class Undici { + async fetch(url) { + return 1; + } + } + + exports.Undici1 = Undici; +} +{ + class Undici { + async fetch(url) { + return 2; + } + } + + exports.Undici2 = Undici; +} +{ + class Undici { + async fetch(url) { + return 3; + } + } + + exports.Undici3 = Undici; +} +{ + class Undici { + async fetch(url) { + return 4; + } + } + + exports.Undici4 = Undici; +} diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/test.js new file mode 100644 index 000000000..8e48e0f26 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/index_cjs/test.js @@ -0,0 +1,31 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const undicis = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Undici_fetch"); + +async function testOne(Undici, num, expectedCtx) { + const undici = new Undici(); + const result = await undici.fetch("https://example.com"); + assert.strictEqual(result, num); + assert.deepStrictEqual(context, expectedCtx); + delete context.start; + delete context.end; + delete context.asyncStart; + delete context.asyncEnd; +} + +(async () => { + await testOne(undicis.Undici0, 0, {}); + await testOne(undicis.Undici1, 1, {}); + await testOne(undicis.Undici2, 2, { + start: true, + end: true, + asyncStart: 2, + asyncEnd: 2, + }); + await testOne(undicis.Undici3, 3, {}); + await testOne(undicis.Undici4, 4, {}); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/mod.js new file mode 100644 index 000000000..94b45a7a1 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/mod.js @@ -0,0 +1,13 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +class Base {} + +class Undici extends Base { + async fetch(url) { + return 42; + } +} + +module.exports = { Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/test.js new file mode 100644 index 000000000..e94a36d5a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/instance_method_subclass_cjs/test.js @@ -0,0 +1,18 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const { Undici } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Base_fetch"); +(async () => { + const undici = new Undici(); + const result = await undici.fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/mod.mjs new file mode 100644 index 000000000..c5cd21cbe --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/mod.mjs @@ -0,0 +1,11 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const J = class { + async fetch(url) { + return 42; + } +}; + +export { J as Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/test.mjs new file mode 100644 index 000000000..8fba82870 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/let_class_export_alias_mjs/test.mjs @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { Undici } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:Undici:fetch"); +const undici = new Undici(); +const result = await undici.fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/mod.js new file mode 100644 index 000000000..55d3a280c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/mod.js @@ -0,0 +1,15 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +class Undici { + async fetch1(url) { + return 42; + } + + async fetch2(url) { + return 43; + } +} + +module.exports = Undici; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/test.js new file mode 100644 index 000000000..98e5f38a2 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_class_method_cjs/test.js @@ -0,0 +1,28 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const Undici = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context1 = getContext("orchestrion:undici:Undici_fetch1"); +const context2 = getContext("orchestrion:undici:Undici_fetch2"); + +(async () => { + const undici = new Undici(); + const result1 = await undici.fetch1("https://example.com"); + assert.strictEqual(result1, 42); + assert.deepStrictEqual(context1, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); + const result2 = await undici.fetch2("https://example.com"); + assert.strictEqual(result2, 43); + assert.deepStrictEqual(context2, { + start: true, + end: true, + asyncStart: 43, + asyncEnd: 43, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/mod.js new file mode 100644 index 000000000..e5568cbe9 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/mod.js @@ -0,0 +1,16 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +class UndiciBase { + async fetch(url) { + return 42; + } +} +class Undici extends UndiciBase { + async fetch(url) { + return super.fetch(url); + } +} + +module.exports = Undici; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/test.js new file mode 100644 index 000000000..bb545a5ef --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/multiple_load_cjs/test.js @@ -0,0 +1,18 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const Undici = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Undici_fetch"); +(async () => { + const undici = new Undici(); + const result = await undici.fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/mod.js new file mode 100644 index 000000000..ad9bebd03 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/mod.js @@ -0,0 +1,18 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ + +function fastify() { + const fastify = { + addHook, + }; + + function addHook() { + return "Hook added"; + } + + return fastify; +} + +module.exports = fastify; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/test.js new file mode 100644 index 000000000..b04cbd031 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/nested_functions/test.js @@ -0,0 +1,16 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const fastify = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:nested_fn"); +(async () => { + const f = fastify(); + const result = f.addHook(); + assert.strictEqual(result, "Hook added"); + assert.deepStrictEqual(context, { + start: true, + end: "Hook added", + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/mod.js new file mode 100644 index 000000000..dd593c8c0 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/mod.js @@ -0,0 +1,11 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const undici = { + async fetch(url) { + return 42; + }, +}; + +module.exports = undici; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/test.js new file mode 100644 index 000000000..bf22412ba --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_method_cjs/test.js @@ -0,0 +1,17 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Undici_fetch"); +(async () => { + const result = await fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/mod.js new file mode 100644 index 000000000..90d59924b --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/mod.js @@ -0,0 +1,14 @@ +"use strict"; + +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +// Named object pattern: async arrow function assigned to a property +// on a named identifier (not `this`). +const conn = {}; +conn.query = async () => { + return 42; +}; + +module.exports = { conn }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/test.js new file mode 100644 index 000000000..204595b5a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_named_cjs/test.js @@ -0,0 +1,20 @@ +"use strict"; + +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const { conn } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:conn_query"); + +(async () => { + const result = await conn.query(); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/mod.js new file mode 100644 index 000000000..75dc24540 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/mod.js @@ -0,0 +1,15 @@ +"use strict"; + +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +// Mimics the mariadb v2 pattern: query methods are arrow functions +// assigned to `this` inside a function constructor. +function Connection(opts) { + this._query = async () => { + return 42; + }; +} + +module.exports = { Connection }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/test.js new file mode 100644 index 000000000..1b895d60c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/object_property_this_cjs/test.js @@ -0,0 +1,21 @@ +"use strict"; + +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const { Connection } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:Connection_query"); + +(async () => { + const conn = new Connection({ host: "localhost" }); + const result = await conn._query(); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/mod.js new file mode 100644 index 000000000..17b5ed007 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/mod.js @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +async function fetch(url) { + return 42; +} + +exports.fetch = fetch; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/polyfill.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/polyfill.js new file mode 100644 index 000000000..a42b11f2f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/polyfill.js @@ -0,0 +1,8 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +module.exports = require("node:diagnostics_channel"); +const tracingChannel = module.exports.tracingChannel; +tracingChannel.polyfilled = true; +module.exports.tracingChannel = tracingChannel; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/test.js new file mode 100644 index 000000000..e0dfcfb29 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_cjs/test.js @@ -0,0 +1,19 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const { tracingChannel } = require("node:diagnostics_channel"); +const context = getContext("orchestrion:undici:fetch_decl"); +(async () => { + const result = await fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); + assert.ok(tracingChannel.polyfilled); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/mod.mjs new file mode 100644 index 000000000..8e57364a2 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/mod.mjs @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +async function fetch(url) { + return 42; +} + +export { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/polyfill.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/polyfill.js new file mode 100644 index 000000000..5ef01bf44 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/polyfill.js @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const dc = require("node:diagnostics_channel"); +const api = {}; +api.tracingChannel = dc.tracingChannel; +api.tracingChannel.polyfilled = true; +module.exports = api; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/test.mjs new file mode 100644 index 000000000..5916b9e61 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/polyfill_mjs/test.mjs @@ -0,0 +1,17 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +import { fetch } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +import { tracingChannel } from "diagnostics_channel"; +const context = getContext("orchestrion:undici:fetch_decl"); +const result = await fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); +assert.ok(tracingChannel.polyfilled); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/mod.js new file mode 100644 index 000000000..ad6947776 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/mod.js @@ -0,0 +1,19 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +class TestClass { + constructor() { + this.prop = true; + } + + async #testMe() { + return 42; + } + + async testMe() { + return this.#testMe(); + } +} + +module.exports = TestClass; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/test.js new file mode 100644 index 000000000..d8ebd2a6c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/private_method_cjs/test.js @@ -0,0 +1,18 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const TestClass = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:TestClass:testMe"); +(async () => { + const test = new TestClass(); + const result = await test.testMe(); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/mod.js new file mode 100644 index 000000000..f60b922bc --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/mod.js @@ -0,0 +1,27 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ + +/** + * A Promise subclass with an extra method, similar to Anthropic SDK's APIPromise. + * The instrumented wrapper must preserve the original return type so callers + * can still access subclass-specific methods like `.withResponse()`. + */ +class ExtendedPromise extends Promise { + withResponse() { + return this.then((result) => ({ data: result, response: { status: 200 } })); + } +} + +/** + * NOTE: Intentionally NOT async. Async functions always wrap their return value + * in a native Promise, losing the subclass type. The real-world scenario + * (e.g. Anthropic SDK's APIPromise) uses a non-async function that explicitly + * constructs and returns a Promise subclass instance. + */ +function fetch(url) { + return new ExtendedPromise((resolve) => resolve(42)); +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/test.js new file mode 100644 index 000000000..daa4db7cc --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/promise_subclass/test.js @@ -0,0 +1,25 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch_subclass"); +(async () => { + const promise = fetch("https://example.com"); + // The instrumented wrapper must return the original promise, not promise.then(), + // so subclass methods like withResponse() remain accessible. + assert.strictEqual( + typeof promise.withResponse, + "function", + "withResponse should be available on the returned promise", + ); + const { data } = await promise.withResponse(); + assert.strictEqual(data, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/mod.mjs new file mode 100644 index 000000000..c5cd21cbe --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/mod.mjs @@ -0,0 +1,11 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const J = class { + async fetch(url) { + return 42; + } +}; + +export { J as Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/test.mjs new file mode 100644 index 000000000..8fba82870 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_class_export_alias_mjs/test.mjs @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { Undici } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:Undici:fetch"); +const undici = new Undici(); +const result = await undici.fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/mod.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/mod.mjs new file mode 100644 index 000000000..e32d817a3 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/mod.mjs @@ -0,0 +1,17 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +class Base { + async fetch(url) { + return 0; + } +} + +const J = class InternalName extends Base { + async fetch(url) { + return 42; + } +}; + +export { J as Undici }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/test.mjs b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/test.mjs new file mode 100644 index 000000000..8fba82870 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/var_named_class_export_alias_mjs/test.mjs @@ -0,0 +1,16 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +import { Undici } from "./instrumented.mjs"; +import { assert, getContext } from "../common/preamble.js"; +const context = getContext("orchestrion:undici:Undici:fetch"); +const undici = new Undici(); +const result = await undici.fetch("https://example.com"); +assert.strictEqual(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, +}); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/mod.js new file mode 100644 index 000000000..c977331ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/mod.js @@ -0,0 +1,9 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +async function fetch(url) { + return 42; +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/test.js new file mode 100644 index 000000000..0c66e7227 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/windows_path/test.js @@ -0,0 +1,17 @@ +/** + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. + * This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2025 Datadog, Inc. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch_decl"); +(async () => { + const result = await fetch("https://example.com"); + assert.strictEqual(result, 42); + assert.deepStrictEqual(context, { + start: true, + end: true, + asyncStart: 42, + asyncEnd: 42, + }); +})(); diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/mod.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/mod.js new file mode 100644 index 000000000..446b93a60 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/mod.js @@ -0,0 +1,9 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +function fetch(url) { + return 42; +} + +module.exports = { fetch }; diff --git a/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/test.js b/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/test.js new file mode 100644 index 000000000..a82f05371 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/orchestrion-js/wrap_promise_non_promise/test.js @@ -0,0 +1,13 @@ +/** + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + **/ +const { fetch } = require("./instrumented.js"); +const { assert, getContext } = require("../common/preamble.js"); +const context = getContext("orchestrion:undici:fetch_nonpromise"); +const result = fetch("https://example.com"); +assert.equal(result, 42); +assert.deepStrictEqual(context, { + start: true, + end: 42, +}); diff --git a/js/tests/auto-instrumentations/orchestrion-js-upstream.test.ts b/js/tests/auto-instrumentations/orchestrion-js-upstream.test.ts new file mode 100644 index 000000000..c124cb7bf --- /dev/null +++ b/js/tests/auto-instrumentations/orchestrion-js-upstream.test.ts @@ -0,0 +1,470 @@ +/* + * Forked from @apm-js-collab/code-transformer@0.12.0 (Orchestrion-JS), + * licensed under Apache-2.0. Modified by Braintrust. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { SourceMapConsumer } from "source-map"; +import { + create, + type InstrumentationConfig, +} from "../../src/auto-instrumentations/orchestrion-js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtureRoot = path.join(__dirname, "fixtures/orchestrion-js"); + +const TEST_MODULE_NAME = "undici"; +const TEST_MODULE_VERSION = "0.0.1"; +const TEST_MODULE_PATH = "index.mjs"; +const WINDOWS_MODULE_PATH = "lib/index.mjs"; + +interface UpstreamFixtureCase { + name: string; + title: string; + configs: InstrumentationConfig[]; + mjs?: boolean; + filePath?: string; + transformerFilePath?: string; + dcModule?: string; +} + +let outputRoot: string; + +function config( + channelName: string, + functionQuery: InstrumentationConfig["functionQuery"], + filePath = TEST_MODULE_PATH, +): InstrumentationConfig { + return { + channelName, + module: { + name: TEST_MODULE_NAME, + versionRange: ">=0.0.1", + filePath, + }, + functionQuery, + }; +} + +function runFixture({ + name, + configs, + mjs = false, + filePath = TEST_MODULE_PATH, + transformerFilePath = filePath, + dcModule, +}: UpstreamFixtureCase): void { + const ext = mjs ? "mjs" : "js"; + const sourceDir = path.join(fixtureRoot, name); + const runDir = path.join(outputRoot, name); + + fs.rmSync(runDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, runDir, { recursive: true }); + + const matcher = create(configs, dcModule); + const transformer = matcher.getTransformer( + TEST_MODULE_NAME, + TEST_MODULE_VERSION, + transformerFilePath, + ); + + expect(transformer).toBeDefined(); + + const code = fs.readFileSync(path.join(runDir, `mod.${ext}`), "utf8"); + const transformed = transformer!.transform(code, mjs ? "esm" : "cjs"); + fs.writeFileSync(path.join(runDir, `instrumented.${ext}`), transformed.code); + + const result = spawnSync(process.execPath, [`test.${ext}`], { + cwd: runDir, + stdio: "pipe", + }); + const output = + (result.stdout?.toString() ?? "") + (result.stderr?.toString() ?? ""); + expect(result.status, output).toBe(0); +} + +// Upstream cases for removed APIs are intentionally omitted: custom transforms, +// constructor-only configs, and expression-name queries. +const fixtureCases: UpstreamFixtureCase[] = [ + { + name: "ast_query_cjs", + title: "instruments using a raw AST query selector", + configs: [ + { + ...config("fetch_ast_query", { kind: "Async" }), + astQuery: 'FunctionDeclaration[id.name="fetch"][async]', + }, + ], + }, + { + name: "arguments_mutation", + title: "supports argument mutation from tracing-channel subscribers", + configs: [ + config("fetch_simple", { functionName: "fetch_simple", kind: "Sync" }), + config("fetch.complex", { + functionName: "fetch_complex", + kind: "Sync", + }), + ], + }, + { + name: "class_expression_cjs", + title: "instruments async class methods on class expressions", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + }), + ], + }, + { + name: "class_method_cjs", + title: "instruments async class methods", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + }), + ], + }, + { + name: "decl_cjs", + title: "instruments async function declarations in CJS", + configs: [config("fetch.decl", { functionName: "fetch", kind: "Async" })], + }, + { + name: "decl_mjs", + title: "instruments async function declarations in ESM", + configs: [config("fetch_decl", { functionName: "fetch", kind: "Async" })], + mjs: true, + }, + { + name: "decl_mjs_mismatched_type", + title: "instruments promise-returning function declarations as async spans", + configs: [config("fetch_decl", { functionName: "fetch", kind: "Async" })], + mjs: true, + }, + { + name: "export_alias_mjs", + title: "instruments function declarations via ESM export aliases", + configs: [ + config("fetch_alias", { + functionName: "fetchAliased", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "export_alias_class_mjs", + title: "instruments class methods via ESM class export aliases", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "const_class_export_alias_mjs", + title: "instruments const class expressions via ESM export aliases", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "let_class_export_alias_mjs", + title: "instruments let class expressions via ESM export aliases", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "var_class_export_alias_mjs", + title: "instruments var class expressions via ESM export aliases", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "var_named_class_export_alias_mjs", + title: "instruments named var class expressions via ESM export aliases", + configs: [ + config("Undici:fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + isExportAlias: true, + }), + ], + mjs: true, + }, + { + name: "index_cjs", + title: "instruments class methods by index", + configs: [ + config("Undici_fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + index: 2, + }), + ], + }, + { + name: "instance_method_subclass_cjs", + title: "instruments inherited methods via constructor patching", + configs: [ + config("Base_fetch", { + className: "Base", + methodName: "fetch", + kind: "Async", + }), + ], + }, + { + name: "iife_nested_class", + title: "instruments classes returned from IIFEs", + configs: [ + config("register", { + className: "Server", + methodName: "register", + kind: "Sync", + }), + ], + }, + { + name: "multiple_class_method_cjs", + title: "instruments multiple class methods in one module", + configs: [ + config("Undici_fetch1", { + className: "Undici", + methodName: "fetch1", + kind: "Async", + }), + config("Undici_fetch2", { + className: "Undici", + methodName: "fetch2", + kind: "Async", + }), + ], + }, + { + name: "multiple_load_cjs", + title: "keeps transformed modules loadable across repeated matcher use", + configs: [ + config("Undici_fetch", { + className: "Undici", + methodName: "fetch", + kind: "Async", + }), + ], + }, + { + name: "nested_functions", + title: "instruments nested sync function declarations", + configs: [config("nested_fn", { functionName: "addHook", kind: "Sync" })], + }, + { + name: "object_method_cjs", + title: "instruments object methods with method-only configs", + configs: [config("Undici_fetch", { methodName: "fetch", kind: "Async" })], + }, + { + name: "object_property_this_cjs", + title: "instruments async functions assigned to this properties", + configs: [ + config("Connection_query", { + objectName: "this", + propertyName: "_query", + kind: "Async", + }), + ], + }, + { + name: "object_property_named_cjs", + title: "instruments async functions assigned to named object properties", + configs: [ + config("conn_query", { + objectName: "conn", + propertyName: "query", + kind: "Async", + }), + ], + }, + { + name: "polyfill_cjs", + title: "supports a custom diagnostics channel module in CJS", + configs: [config("fetch_decl", { functionName: "fetch", kind: "Async" })], + dcModule: "./polyfill.js", + }, + { + name: "polyfill_mjs", + title: "supports a custom diagnostics channel module in ESM", + configs: [config("fetch_decl", { functionName: "fetch", kind: "Async" })], + dcModule: "./polyfill.js", + mjs: true, + }, + { + name: "promise_subclass", + title: "preserves Promise subclass return values", + configs: [ + config("fetch_subclass", { functionName: "fetch", kind: "Async" }), + ], + }, + { + name: "private_method_cjs", + title: "instruments async private class methods", + configs: [ + config("TestClass:testMe", { + className: "TestClass", + privateMethodName: "testMe", + kind: "Async", + }), + ], + }, + { + name: "callback_cjs", + title: "instruments callback-style functions", + configs: [config("fetch.cb", { functionName: "fetch", kind: "Callback" })], + }, + { + name: "windows_path", + title: "matches Windows-style transformed file paths", + configs: [ + config( + "fetch_decl", + { functionName: "fetch", kind: "Async" }, + WINDOWS_MODULE_PATH, + ), + ], + transformerFilePath: "lib\\index.mjs", + }, + { + name: "wrap_promise_non_promise", + title: "handles non-Promise returns from Async configs", + configs: [ + config("fetch_nonpromise", { functionName: "fetch", kind: "Async" }), + ], + }, +]; + +describe("Orchestrion-JS upstream-derived behavior", () => { + beforeAll(() => { + outputRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "braintrust-orchestrion-js-"), + ); + fs.cpSync( + path.join(fixtureRoot, "common"), + path.join(outputRoot, "common"), + { recursive: true }, + ); + }); + + afterAll(() => { + fs.rmSync(outputRoot, { recursive: true, force: true }); + }); + + for (const testCase of fixtureCases) { + it(testCase.title, () => { + runFixture(testCase); + }); + } + + it("accepts Buffer input and produces the same output as string input", () => { + const code = [ + "async function fetch (url) {", + " return 42;", + "}", + "module.exports = { fetch };", + ].join("\n"); + + const matcher = create([ + config("fetch", { functionName: "fetch", kind: "Async" }), + ]); + const transformer = matcher.getTransformer( + TEST_MODULE_NAME, + TEST_MODULE_VERSION, + TEST_MODULE_PATH, + ); + + expect(transformer).toBeDefined(); + const fromString = transformer!.transform(code, "cjs"); + const fromBuffer = transformer!.transform(Buffer.from(code), "cjs"); + + expect(fromBuffer.code).toEqual(fromString.code); + }); + + it("maps generated positions back to original line and column", async () => { + const originalCode = [ + "async function fetch (url) {", + " return 42;", + "}", + "module.exports = { fetch };", + ].join("\n"); + + const matcher = create([ + config("fetch_sm", { functionName: "fetch", kind: "Async" }), + ]); + const transformer = matcher.getTransformer( + TEST_MODULE_NAME, + TEST_MODULE_VERSION, + TEST_MODULE_PATH, + ); + + expect(transformer).toBeDefined(); + const { code: generatedCode, map } = transformer!.transform( + originalCode, + "cjs", + ); + + expect(map).toBeDefined(); + + await SourceMapConsumer.with(JSON.parse(map!), null, (consumer) => { + const generatedLines = generatedCode.split("\n"); + const generatedLine = + generatedLines.findIndex((line) => line.includes("return 42")) + 1; + + expect(generatedLine).toBeGreaterThan(0); + + const generatedColumn = + generatedLines[generatedLine - 1]?.indexOf("42") ?? -1; + const original = consumer.originalPositionFor({ + line: generatedLine, + column: generatedColumn, + }); + + expect(original.line).toBe(2); + expect(original.column).toBe(9); + }); + }); +}); diff --git a/js/tests/auto-instrumentations/transformation.test.ts b/js/tests/auto-instrumentations/transformation.test.ts index fdc3bc4c6..fc402096b 100644 --- a/js/tests/auto-instrumentations/transformation.test.ts +++ b/js/tests/auto-instrumentations/transformation.test.ts @@ -1,8 +1,8 @@ /** * ORCHESTRION TRANSFORMATION TESTS * - * These tests verify that @apm-js-collab/code-transformer (orchestrion) - * correctly transforms code to inject tracingChannel calls at build time. + * These tests verify that the internal Orchestrion-JS fork correctly + * transforms code to inject tracingChannel calls at build time. * * IMPORTANT: Tests use a mock OpenAI package structure in test/fixtures/node_modules/openai. * IMPORTANT: dc-browser is now an npm package dependency. @@ -14,12 +14,50 @@ import { build as viteBuild } from "vite"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { + create, + type InstrumentationConfig, +} from "../../src/auto-instrumentations/orchestrion-js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, "fixtures"); const outputDir = path.join(__dirname, "output-transformation"); const nodeModulesDir = path.join(fixturesDir, "node_modules"); +function testConfig( + functionQuery: InstrumentationConfig["functionQuery"], + astQuery?: string, +): InstrumentationConfig { + const config: InstrumentationConfig = { + channelName: "test", + module: { + name: "test-sdk", + versionRange: ">=1.0.0", + filePath: "index.mjs", + }, + functionQuery, + }; + + if (astQuery) { + config.astQuery = astQuery; + } + + return config; +} + +function transformTestCode( + functionQuery: InstrumentationConfig["functionQuery"], + code: string, + moduleType: "esm" | "cjs" = "esm", + astQuery?: string, +) { + const matcher = create([testConfig(functionQuery, astQuery)]); + const transformer = matcher.getTransformer("test-sdk", "1.0.0", "index.mjs"); + + expect(transformer).toBeDefined(); + return transformer!.transform(code, moduleType); +} + describe("Orchestrion Transformation Tests", () => { beforeAll(() => { // Create output directory @@ -37,6 +75,205 @@ describe("Orchestrion Transformation Tests", () => { } }); + describe("internal transformer query surface", () => { + it("supports class method configs", () => { + const result = transformTestCode( + { className: "Client", methodName: "create", kind: "Async" }, + ` + export class Client { + async create(input) { + return input; + } + } + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports method-only configs", () => { + const result = transformTestCode( + { methodName: "create", kind: "Async" }, + ` + export const client = { + create: async function (input) { + return input; + }, + }; + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports function declaration configs", () => { + const result = transformTestCode( + { functionName: "query", kind: "Sync" }, + ` + export function query(input) { + return input; + } + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports export-alias function configs", () => { + const result = transformTestCode( + { functionName: "query", kind: "Sync", isExportAlias: true }, + ` + function queryImpl(input) { + return input; + } + export { queryImpl as query }; + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports export-alias class method configs", () => { + const result = transformTestCode( + { + className: "Client", + methodName: "create", + kind: "Async", + isExportAlias: true, + }, + ` + class Impl { + async create(input) { + return input; + } + } + export { Impl as Client }; + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports private class method configs", () => { + const result = transformTestCode( + { className: "Client", privateMethodName: "create", kind: "Async" }, + ` + class Client { + async #create(input) { + return input; + } + + async run(input) { + return this.#create(input); + } + } + module.exports = Client; + `, + "cjs", + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports object/property configs", () => { + const result = transformTestCode( + { objectName: "this", propertyName: "create", kind: "Async" }, + ` + function Client() { + this.create = async () => { + return "ok"; + }; + } + module.exports = Client; + `, + "cjs", + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports callback configs", () => { + const result = transformTestCode( + { functionName: "request", kind: "Callback" }, + ` + export function request(input, callback) { + callback(null, input); + } + `, + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("Array.prototype.splice.call"); + expect(result.code).toContain("tr_ch_apm$test.asyncStart.runStores"); + }); + + it("supports raw AST query configs", () => { + const result = transformTestCode( + { kind: "Async" }, + ` + export async function request(input) { + return input; + } + `, + "esm", + 'FunctionDeclaration[id.name="request"][async]', + ); + + expect(result.code).toContain("orchestrion:test-sdk:test"); + expect(result.code).toContain("tr_ch_apm$test.start.runStores"); + }); + + it("supports index selection", () => { + const result = transformTestCode( + { methodName: "create", kind: "Async", index: 1 }, + ` + export const first = { + create: async function firstCreate() { + return "first"; + }, + }; + export const second = { + create: async function secondCreate() { + return "second"; + }, + }; + `, + ); + + const wrapperCount = result.code.match( + /tr_ch_apm\$test\.start\.runStores/g, + ); + expect(wrapperCount).toHaveLength(1); + expect(result.code.indexOf("secondCreate")).toBeLessThan( + result.code.indexOf("tr_ch_apm$test.start.runStores"), + ); + }); + + it("generates source maps", () => { + const result = transformTestCode( + { functionName: "query", kind: "Sync" }, + ` + export function query(input) { + return input; + } + `, + ); + + expect(result.map).toBeDefined(); + expect(JSON.parse(result.map!)).toMatchObject({ + version: 3, + file: "test-sdk/index.mjs", + }); + }); + }); + describe("esbuild", () => { it("should transform OpenAI SDK code with tracingChannel", async () => { const { braintrustEsbuildPlugin } = diff --git a/js/tsup.config.ts b/js/tsup.config.ts index aec965614..39e73e34b 100644 --- a/js/tsup.config.ts +++ b/js/tsup.config.ts @@ -106,7 +106,7 @@ export default defineConfig([ format: ["cjs", "esm"], outDir: "dist/auto-instrumentations", dts: true, - external: ["@apm-js-collab/code-transformer", "zod"], + external: ["zod"], outExtension({ format }) { if (format === "esm") { return { js: ".mjs" }; @@ -123,7 +123,7 @@ export default defineConfig([ format: ["cjs"], outDir: "dist/auto-instrumentations", dts: true, - external: ["@apm-js-collab/code-transformer", "zod"], + external: ["zod"], outExtension() { return { js: ".cjs" }; }, @@ -138,7 +138,7 @@ export default defineConfig([ outDir: "dist/auto-instrumentations", dts: false, platform: "node", - external: ["@apm-js-collab/code-transformer", "@anthropic-ai/sdk", "zod"], + external: ["@anthropic-ai/sdk", "zod"], outExtension({ format }) { return { js: ".mjs" }; }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6892f97c5..1ed3276e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,12 +332,12 @@ importers: js: dependencies: - '@apm-js-collab/code-transformer': - specifier: ^0.12.0 - version: 0.12.0 '@next/env': specifier: ^14.2.3 version: 14.2.3 + '@types/estree': + specifier: ^1.0.8 + version: 1.0.9 '@vercel/functions': specifier: ^1.0.2 version: 1.0.2 @@ -347,6 +347,9 @@ importers: argparse: specifier: ^2.0.1 version: 2.0.1 + astring: + specifier: ^1.9.0 + version: 1.9.0 cli-progress: specifier: ^3.12.0 version: 3.12.0 @@ -365,6 +368,9 @@ importers: esbuild: specifier: 0.27.4 version: 0.27.4 + esquery: + specifier: ^1.7.0 + version: 1.7.0 eventsource-parser: specifier: ^1.1.2 version: 1.1.2 @@ -374,6 +380,9 @@ importers: http-errors: specifier: ^2.0.0 version: 2.0.1 + meriyah: + specifier: ^6.1.4 + version: 6.1.4 minimatch: specifier: ^10.2.5 version: 10.2.5 @@ -386,6 +395,9 @@ importers: pluralize: specifier: ^8.0.0 version: 8.0.0 + semifies: + specifier: ^1.0.0 + version: 1.0.0 simple-git: specifier: ^3.36.0 version: 3.36.0 @@ -602,9 +614,6 @@ packages: zod: optional: true - '@apm-js-collab/code-transformer@0.12.0': - resolution: {integrity: sha512-5F2ob4cMYezbaUGAk+YltbDvb9BFIghN92ubct9Ho/0MFx4FkChCxYV99NkU6Kx+RAgaqBV6yxKuWreQ6K8SOw==} - '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -4255,15 +4264,6 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@apm-js-collab/code-transformer@0.12.0': - dependencies: - '@types/estree': 1.0.9 - astring: 1.9.0 - esquery: 1.7.0 - meriyah: 6.1.4 - semifies: 1.0.0 - source-map: 0.6.1 - '@babel/runtime@7.28.4': {} '@braintrust/bt-darwin-arm64@0.12.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 31bde25c3..dd63d0ea2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,6 @@ packages: blockExoticSubdeps: true minimumReleaseAge: 4320 # 3 days (in minutes) minimumReleaseAgeExclude: - - "@apm-js-collab/*" # instrumentation deps we need to pin closely - "@braintrust/bt-*" # bt binary packages, published in lockstep with braintrust trustPolicy: no-downgrade # Ignore the check for packages published more than 30 days ago (pnpm 10.27+) From a64078e7b78a8660f33758f870f7df64b14e81df Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 23 Jun 2026 09:47:03 +0200 Subject: [PATCH 2/7] cs --- .changeset/hot-boats-fail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-boats-fail.md diff --git a/.changeset/hot-boats-fail.md b/.changeset/hot-boats-fail.md new file mode 100644 index 000000000..8c7aeddd2 --- /dev/null +++ b/.changeset/hot-boats-fail.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +ref: Vendor orchestrion-js From 2f91c35619610a08a75cba9b5bf5b92df42115d7 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 23 Jun 2026 14:13:14 +0200 Subject: [PATCH 3/7] ref: Fork `import-in-the-middle` and `require-in-the-middle` --- AGENTS.md | 8 + js/NOTICE | 8 + js/licenses/import-in-the-middle/LICENSE | 201 +++++ .../import-in-the-middle/LICENSE-3rdparty.csv | 4 + js/licenses/import-in-the-middle/NOTICE | 34 + js/licenses/require-in-the-middle/LICENSE | 23 + js/package.json | 3 + .../import-in-the-middle/create-hook.mjs | 748 ++++++++++++++++++ .../import-in-the-middle/hook.mjs | 9 + .../import-in-the-middle/index.d.ts | 25 + .../import-in-the-middle/index.js | 193 +++++ .../lib/get-esm-exports.mjs | 116 +++ .../import-in-the-middle/lib/get-exports.mjs | 404 ++++++++++ .../import-in-the-middle/lib/io.mjs | 78 ++ .../import-in-the-middle/lib/register.js | 88 +++ .../import-in-the-middle/register-hooks.d.ts | 4 + .../import-in-the-middle/register-hooks.mjs | 50 ++ .../orchestrion-js/index.ts | 2 +- .../require-in-the-middle/index.js | 252 ++++++ .../require-in-the-middle/types/index.d.ts | 10 + .../fixtures/vendor-hooks/.gitignore | 2 + .../fixtures/vendor-hooks/iitm-async-app.mjs | 10 + .../vendor-hooks/iitm-async-setup.mjs | 36 + .../fixtures/vendor-hooks/iitm-sync-app.mjs | 50 ++ .../node_modules/cjs-hook-target/index.cjs | 3 + .../node_modules/cjs-hook-target/package.json | 8 + .../node_modules/hook-target/index.mjs | 4 + .../node_modules/hook-target/package.json | 8 + .../node_modules/ritm-other/index.js | 3 + .../node_modules/ritm-other/package.json | 5 + .../node_modules/ritm-target/index.js | 3 + .../node_modules/ritm-target/package.json | 5 + .../node_modules/unhooked-target/index.mjs | 4 + .../node_modules/unhooked-target/package.json | 8 + .../fixtures/vendor-hooks/ritm-app.cjs | 38 + .../import-require-in-the-middle.test.ts | 83 ++ pnpm-lock.yaml | 23 + 37 files changed, 2552 insertions(+), 1 deletion(-) create mode 100644 js/licenses/import-in-the-middle/LICENSE create mode 100644 js/licenses/import-in-the-middle/LICENSE-3rdparty.csv create mode 100644 js/licenses/import-in-the-middle/NOTICE create mode 100644 js/licenses/require-in-the-middle/LICENSE create mode 100644 js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/hook.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/index.d.ts create mode 100644 js/src/auto-instrumentations/import-in-the-middle/index.js create mode 100644 js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/lib/register.js create mode 100644 js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts create mode 100644 js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs create mode 100644 js/src/auto-instrumentations/require-in-the-middle/index.js create mode 100644 js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/.gitignore create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/index.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/index.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/index.js create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/index.js create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/index.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs create mode 100644 js/tests/auto-instrumentations/import-require-in-the-middle.test.ts diff --git a/AGENTS.md b/AGENTS.md index c9e368658..21c5befc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,3 +67,11 @@ pnpm run lint # Run eslint checks pnpm run fix:formatting # Auto-fix formatting pnpm run fix:lint # Auto-fix eslint issues ``` + +## Vendored Forks and Licenses + +When touching forked third-party code in this repository, including the +vendored `import-in-the-middle`, `require-in-the-middle`, or `orchestrion-js` +code, preserve and respect the upstream license requirements. Keep copyright +notices, license files, provenance notes, and `js/NOTICE` entries accurate when +copying, updating, or materially modifying vendored code. diff --git a/js/NOTICE b/js/NOTICE index 31de6cfcf..4a357d5a7 100644 --- a/js/NOTICE +++ b/js/NOTICE @@ -10,3 +10,11 @@ This product includes software developed at Datadog (= 26) return true; + if (NODE_MAJOR === 25) return NODE_MINOR >= 1; + if (NODE_MAJOR === 24) + return NODE_MINOR > 11 || (NODE_MINOR === 11 && NODE_PATCH >= 1); + if (NODE_MAJOR === 22) + return NODE_MINOR > 22 || (NODE_MINOR === 22 && NODE_PATCH >= 3); + return false; +} + +function hasIitm(url) { + // Fast path: avoid URL parsing on the hot path when there's clearly no iitm. + if (typeof url !== "string" || url.indexOf("iitm") === -1) { + return false; + } + try { + return new URL(url).searchParams.has("iitm"); + } catch { + return false; + } +} + +function isIitm(url, meta) { + return ( + url === meta.url || url === meta.url.replace("hook.mjs", "create-hook.mjs") + ); +} + +function deleteIitm(url) { + // Fast path: avoid URL parsing / try-catch on bare specifiers and normal file URLs. + if (typeof url !== "string" || url.indexOf("iitm") === -1) { + return url; + } + let resultUrl; + const stackTraceLimit = Error.stackTraceLimit; + try { + Error.stackTraceLimit = 0; + const urlObj = new URL(url); + if (urlObj.searchParams.has("iitm")) { + urlObj.searchParams.delete("iitm"); + resultUrl = urlObj.href; + if (resultUrl.startsWith("file:///node:")) { + resultUrl = resultUrl.replace("file:///", ""); + } + } else { + resultUrl = urlObj.href; + } + } catch { + resultUrl = url; + } + Error.stackTraceLimit = stackTraceLimit; + return resultUrl; +} + +/** + * Determines if a specifier represents an export all ESM line. + * Note that the expected `line` isn't 100% valid ESM. It is derived + * from the `getExports` function wherein we have recognized the true + * line and re-mapped it to one we expect. + * + * @param {string} line + * @returns {boolean} + */ +function isStarExportLine(line) { + return /^\* from /.test(line); +} + +function isBareSpecifier(specifier) { + // Relative and absolute paths are not bare specifiers. + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return false; + } + + // Valid URLs are not bare specifiers. (file:, http:, node:, etc.) + + // eslint-disable-next-line no-prototype-builtins + if (URL.hasOwnProperty("canParse")) { + return !URL.canParse(specifier); + } + + const stackTraceLimit = Error.stackTraceLimit; + try { + Error.stackTraceLimit = 0; + // eslint-disable-next-line no-new + new URL(specifier); + return false; + } catch (err) { + return true; + } finally { + Error.stackTraceLimit = stackTraceLimit; + } +} + +function emitWarning(err) { + // Unfortunately, process.emitWarning does not output the full error + // with error.cause like console.warn does so we need to inspect it when + // tracing warnings + const warnMessage = TRACE_WARNINGS ? inspect(err) : err; + process.emitWarning(warnMessage); +} + +/** + * Builds the setter/getter/re-export block injected into the wrapper module for + * a single named export. This is pure string generation, identical regardless + * of how the loader is driven, so both the synchronous and asynchronous paths + * share it. + * + * @param {string} n The exported name. + * @param {string} srcUrl The URL of the module the export belongs to. + * @returns {string} + */ +function buildSetter(n, srcUrl) { + const variableName = `$${n.replace(/[^a-zA-Z0-9_$]/g, "_")}`; + const objectKey = JSON.stringify(n); + const reExportedName = n === "default" ? n : objectKey; + + // For the module.exports synthetic export (Node 23+), fall back to $default + // when namespace['module.exports'] is not exposed by the native ESM namespace + // (builtins don't expose it). This ensures the IITM hook proxy returns the + // actual CJS value (e.g. EventEmitter) when an instrumentor reads + // capturedExports['module.exports'], rather than undefined. + const moduleExportsFallback = n === "module.exports" ? " ?? $default" : ""; + + const reExportLine = + n === "module.exports" && + (srcUrl.startsWith("node:") || builtinModules.includes(srcUrl)) + ? "" + : `export { ${variableName} as ${reExportedName} }`; + + return ` + let ${variableName} + __overridden[${objectKey}] = false + let ${variableName}Defer = false + try { + ${variableName} = _[${objectKey}] = namespace[${objectKey}]${moduleExportsFallback} + } catch (err) { + if (!(err instanceof ReferenceError)) throw err + ${variableName}Defer = true + } + + if (${variableName}Defer || ${variableName} === undefined) { + __pending.push(__makeUpdater( + ${objectKey}, + () => namespace[${objectKey}]${moduleExportsFallback}, + (v) => { ${variableName} = _[${objectKey}] = v } + )) + } + ${reExportLine} + set[${objectKey}] = (v) => { + __overridden[${objectKey}] = true + ${variableName} = v + return true + } + get[${objectKey}] = () => ${variableName} + `; +} + +/** + * Processes a module's exports and builds a set of setter blocks. + * + * Written as a "sans-io" generator (see `lib/io.mjs`): instead of calling the + * loader's resolve/load hooks directly it `yield`s `[RESOLVE, ...]` to resolve + * star re-exports and `[LOAD, ...]` (via {@link getExports}) to read source, + * and is driven by either {@link driveSync} (for + * `module.registerHooks`) or {@link driveAsync} (for `module.register`). The + * body is identical for both, so there is a single implementation to maintain. + * + * @param {object} params + * @param {string} params.srcUrl The full URL to the module to process. + * @param {object} params.context Provided by the loaders API. + * @param {boolean} [params.excludeDefault = false] Exclude the default export. + * + * @returns {Generator>} A generator that yields I/O + * operations and ultimately returns the shimmed setters for all the exports + * from the module and any transitive export all modules. + */ +function* processModule({ srcUrl, context, excludeDefault = false }) { + const exportNames = yield* getExports(srcUrl, context); + const starExports = new Set(); + const setters = new Map(); + + const addSetter = (name, setter, isStarExport = false) => { + if (setters.has(name)) { + if (isStarExport) { + // If there's already a matching star export, delete it + if (starExports.has(name)) { + setters.delete(name); + } + // and return so this is excluded + return; + } + + // if we already have this export but it is from a * export, overwrite it + if (starExports.has(name)) { + starExports.delete(name); + setters.set(name, setter); + } + } else { + // Store export * exports so we know they can be overridden by explicit + // named exports + if (isStarExport) { + starExports.add(name); + } + + setters.set(name, setter); + } + }; + + for (const n of exportNames) { + if (excludeDefault) { + const isDefault = + n === "default" || + (n === "module.exports" && + context.format === "commonjs" && + hasModuleExportsCJSDefault); + + if (isDefault) continue; + } + + if (isStarExportLine(n) === true) { + const [, modFile] = n.split("* from "); + + // Relative paths need to be resolved relative to the parent module + const newSpecifier = isBareSpecifier(modFile) + ? modFile + : new URL(modFile, srcUrl).href; + // We need to resolve bare specifiers to a full URL. We also need to + // resolve all sub-modules to get the `format`. We can't rely on the + // parent's `format` to know if this sub-module is ESM or CJS! + const result = yield [RESOLVE, newSpecifier, { parentURL: srcUrl }]; + + const subSetters = yield* processModule({ + srcUrl: result.url, + context: { ...context, format: result.format }, + excludeDefault: true, + }); + + for (const [name, setter] of subSetters.entries()) { + addSetter(name, setter, true); + } + } else { + addSetter(n, buildSetter(n, srcUrl)); + } + } + + return setters; +} + +function addIitm(url) { + const urlObj = new URL(url); + urlObj.searchParams.set("iitm", "true"); + return urlObj.href; +} + +export function createHook(meta) { + let cachedResolve; + const iitmURL = new URL("lib/register.js", meta.url).toString(); + const includeModules = new Set(); + + // Track CJS module URLs that IITM has wrapped. On Node 24+, CJS modules loaded + // via loadCJSModule (in an ESM import chain) have their require() calls for + // builtins routed through the ESM resolver. Without this guard, IITM would + // intercept those require() calls and return an ESM namespace object instead + // of the native CJS module value (e.g. EventEmitter constructor), breaking + // patterns like `class App extends require('events') {}`. + const cjsInIitmChain = new Set(); + + function addExplicitIncludes(modules) { + if (!Array.isArray(modules)) { + return; + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + `Braintrust import-in-the-middle only supports string module names. Invalid entry: ${inspect(each)}`, + ); + } + includeModules.add(each); + if (!each.startsWith("node:") && builtinModules.includes(each)) { + includeModules.add(`node:${each}`); + } + } + } + + function defaultShouldInclude(url, specifier) { + const modules = + includeModules.size > 0 ? includeModules : registerState.hookedModules; + if (!modules || modules.size === 0) { + return false; + } + + let resultPath; + if (url.startsWith("file:")) { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + try { + resultPath = fileURLToPath(url); + } catch {} + Error.stackTraceLimit = stackTraceLimit; + } + function match(each) { + return ( + each === specifier || + each === url || + (resultPath && each === resultPath) || + (!each.startsWith("node:") && + url === `node:${each}` && + builtinModules.includes(each)) + ); + } + + for (const each of modules) { + if (match(each)) { + return true; + } + } + return false; + } + + function applyOptions(data) { + if (data.exclude || data.shouldInclude) { + throw new Error( + "Braintrust import-in-the-middle only supports explicit Hook([...]) module interception", + ); + } + + addExplicitIncludes(data.include); + + if (data.addHookMessagePort) { + data.addHookMessagePort + .on("message", (modules) => { + addExplicitIncludes(modules); + data.addHookMessagePort.postMessage("ack"); + }) + .unref(); + } + } + + async function initialize(data) { + if (global.__import_in_the_middle_initialized__) { + process.emitWarning( + "The 'import-in-the-middle' hook has already been initialized", + ); + } + + global.__import_in_the_middle_initialized__ = true; + + if (data) { + applyOptions(data); + } + } + + // Shared post-processing for the `resolve` hook: everything that happens + // once the parent loader has turned the specifier into a resolved URL. The + // only difference between the asynchronous and synchronous hooks is whether + // that resolution was awaited, so all the wrapping decisions live here. + function finishResolve(result, specifier, context, parentURL) { + // Do not wrap the entrypoint module. Many CLIs check whether they are the + // "main" module (e.g. require.main === module). Wrapping changes how they + // are evaluated, and can make them exit without doing anything. + if (parentURL === "") { + if (!EXTENSION_RE.test(result.url) && !hasIitm(result.url)) { + return { url: result.url, format: "commonjs" }; + } + return result; + } + + // Never wrap a module whose format we don't handle (e.g. json, wasm); this + // holds regardless of how inclusion is decided below. + if (result.format && !HANDLED_FORMATS.has(result.format)) { + return result; + } + + // The synchronous hooks (`module.registerHooks`) fire for `require()` as well + // as `import`, but iitm only owns the ESM graph: CommonJS modules are + // instrumented separately through require-in-the-middle, and `require()` must + // return the native, mutable module value (e.g. graceful-fs does + // `Object.defineProperty(require('fs'), ...)`, which throws on a frozen ESM + // namespace). Node reports the active module system in `context.conditions` + // ('require' vs 'import'), so leave any require() resolution untouched. The + // asynchronous hook never sees the 'require' condition, so this is a no-op + // there and only affects the synchronous path. + if (context.conditions?.includes("require")) { + return result; + } + + if (!defaultShouldInclude(result.url, specifier)) { + return result; + } + + if (isIitm(parentURL, meta) || (parentURL && hasIitm(parentURL))) { + return result; + } + + // When a CJS module is loaded by an IITM shim, its require() calls for + // builtins may be routed through the ESM resolver on Node 24+. Skip IITM + // wrapping in that case so require() returns the native module value. + // We also propagate the membership to the resolved child so that its own + // transitive require() calls are likewise skipped (the entire synchronous + // CJS require chain must remain unwrapped to avoid ERR_VM_MODULE_LINK_FAILURE). + if (cjsInIitmChain.has(parentURL)) { + cjsInIitmChain.add(result.url); + return result; + } + + // We don't want to attempt to wrap native modules + if (result.url.endsWith(".node")) { + return result; + } + + // Node.js v21 renames importAssertions to importAttributes + const importAttributes = + context.importAttributes || context.importAssertions; + if (importAttributes && importAttributes.type === "json") { + return result; + } + + // If the file is referencing itself, we need to skip adding the iitm search params + if (result.url === parentURL) { + return { + url: result.url, + shortCircuit: true, + format: result.format, + }; + } + + specifiers.set(result.url, specifier); + + return { + url: addIitm(result.url), + shortCircuit: true, + // Node's synchronous resolver drops `format: 'builtin'` for bare builtin + // specifiers (`require('crypto')` -> `node:crypto`), so restore it; + // otherwise the load hook reads `node:crypto` from disk and throws ENOENT. + format: + result.format ?? + (result.url.startsWith("node:") ? "builtin" : undefined), + }; + } + + async function resolve(specifier, context, parentResolve) { + cachedResolve = parentResolve; + + // See https://github.com/nodejs/import-in-the-middle/pull/76. + if (specifier === iitmURL) { + return { + url: specifier, + shortCircuit: true, + }; + } + + const { parentURL = "" } = context; + const newSpecifier = deleteIitm(specifier); + if (isWin && parentURL.indexOf("file:node") === 0) { + context.parentURL = ""; + } + const result = await parentResolve(newSpecifier, context); + + return finishResolve(result, specifier, context, parentURL); + } + + // Synchronous counterpart to `resolve`, for `module.registerHooks`. The + // synchronous `nextResolve` returns its result directly. We stash it so the + // synchronous `load` hook can resolve star re-exports later, mirroring how + // `resolve` caches `parentResolve`. + function resolveSync(specifier, context, nextResolve) { + cachedResolve = nextResolve; + + if (specifier === iitmURL) { + return { + url: specifier, + shortCircuit: true, + }; + } + + const { parentURL = "" } = context; + const newSpecifier = deleteIitm(specifier); + if (isWin && parentURL.indexOf("file:node") === 0) { + context.parentURL = ""; + } + const result = nextResolve(newSpecifier, context); + + return finishResolve(result, specifier, context, parentURL); + } + + // Builds the wrapper module source that re-exports the real module through + // iitm's proxy. Pure string generation shared by the asynchronous and + // synchronous `load` paths. + function buildWrapperSource(realUrl, setters, originalSpecifier) { + return ` +import { register } from '${iitmURL}' +import * as namespace from ${JSON.stringify(realUrl)} + +// Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects). +const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } }) +const set = {} +const get = {} +const __overridden = Object.create(null) +let __pending = [] + +function __makeUpdater (key, read, assign) { + return () => { + if (__overridden[key] === true) return true + try { + const v = read() + if (v !== undefined) { + assign(v) + return true + } + return false + } catch (err) { + if (err instanceof ReferenceError) return false + throw err + } + } +} + +function __flushPendingOnce () { + if (__pending.length === 0) return + const next = [] + for (const fn of __pending) { + // If it still throws ReferenceError, keep it for the (single) next attempt. + if (fn() !== true) next.push(fn) + } + __pending = next +} + +${Array.from(setters.values()).join("\n")} + +if (__pending.length > 0) { + queueMicrotask(() => { + __flushPendingOnce() + + if (__pending.length > 0) { + const __retryDelays = [0, 10, 50] + const __schedulePending = (i) => { + if (__pending.length === 0) return + if (i >= __retryDelays.length) { + // Give up: leave exports as-is to avoid unbounded retries. + __pending = [] + return + } + + const t = setTimeout(() => { + __flushPendingOnce() + __schedulePending(i + 1) + }, __retryDelays[i]) + // Don't keep the process alive just for best-effort retries. + if (t && typeof t.unref === 'function') t.unref() + } + + __schedulePending(0) + } + }) +} + +register(${JSON.stringify(realUrl)}, _, set, get, ${JSON.stringify(originalSpecifier)}) +`; + } + + // Bookkeeping shared by the async and sync wrap paths once `processModule` + // succeeds: free the specifier entry early, and remember CJS modules so their + // transitive require() chain bypasses iitm (see `load`). Returns the wrapper + // module source. + function onWrapSuccess(realUrl, context, originalSpecifier, setters) { + specifiers.delete(realUrl); + // context.format is set to 'commonjs' by getCjsExports during processModule. + if (context.format === "commonjs") { + cjsInIitmChain.add(realUrl); + } + return buildWrapperSource(realUrl, setters, originalSpecifier); + } + + // Bookkeeping shared by the async and sync wrap paths when `processModule` + // throws. iitm falls back to the parent loader so the module loads unwrapped + // (it just can't be Hook'ed) rather than taking down the whole app. We free + // the specifier entry to avoid a leak, and log because a failure here is + // usually an iitm bug and would otherwise be very tricky to debug. + function onWrapFailure(realUrl, cause) { + specifiers.delete(realUrl); + const err = new Error(`'import-in-the-middle' failed to wrap '${realUrl}'`); + err.cause = cause; + emitWarning(err); + } + + async function getSource(url, context, parentGetSource) { + if (hasIitm(url)) { + const realUrl = deleteIitm(url); + const originalSpecifier = specifiers.get(realUrl); + + try { + const setters = await driveAsync( + processModule({ srcUrl: realUrl, context }), + { resolve: cachedResolve, load: parentGetSource }, + ); + return { + source: onWrapSuccess(realUrl, context, originalSpecifier, setters), + }; + } catch (cause) { + onWrapFailure(realUrl, cause); + // Revert back to the non-iitm URL + url = realUrl; + } + } + + return parentGetSource(url, context); + } + + // Synchronous counterpart to `getSource`, for `module.registerHooks`. Drives + // `processModule` straight through; all bookkeeping and source generation is + // shared with `getSource`. + function getSourceSync(url, context, nextLoad) { + if (hasIitm(url)) { + const realUrl = deleteIitm(url); + const originalSpecifier = specifiers.get(realUrl); + + try { + const setters = driveSync(processModule({ srcUrl: realUrl, context }), { + resolve: cachedResolve, + load: nextLoad, + }); + return { + source: onWrapSuccess(realUrl, context, originalSpecifier, setters), + }; + } catch (cause) { + onWrapFailure(realUrl, cause); + url = realUrl; + } + } + + return nextLoad(url, context); + } + + async function load(url, context, parentLoad) { + if (hasIitm(url)) { + const result = await getSource(url, context, parentLoad); + // If wrapping failed, `getSource()` may have fallen back to `parentLoad`, + // which can legally return `source: null` (e.g. for non-JS formats). + if (result && typeof result === "object" && result.source != null) { + return { + source: result.source, + shortCircuit: true, + format: "module", + }; + } + + // Fall back to the parent loader with the original (non-iitm) URL. + return parentLoad(deleteIitm(url), context); + } + + // On Node 22+, when a CJS module is loaded through the ESM translator and + // another loader hook provides its source (instead of leaving source null + // for Node to read natively), require() calls inside that CJS module for + // packages using the "module-sync" exports condition fail with + // ERR_VM_MODULE_LINK_FAILURE. Work around this Node bug by stripping + // hook-provided source for CJS modules in the synchronous require chain, + // forcing Node to use its native CJS loader which handles this correctly. + if (cjsInIitmChain.has(url)) { + const result = await parentLoad(url, context); + if (result.format === "commonjs" && result.source != null) { + return { + format: result.format, + source: undefined, + }; + } + return result; + } + + return parentLoad(url, context); + } + + // Synchronous counterpart to `load`, for `module.registerHooks`. Mirrors the + // async `load` exactly — wrapping via `getSourceSync` and applying the same + // CJS-in-iitm-chain source stripping — only without awaiting. + function loadSync(url, context, nextLoad) { + if (hasIitm(url)) { + const result = getSourceSync(url, context, nextLoad); + // If wrapping failed, `getSourceSync()` may have fallen back to `nextLoad`, + // which can legally return `source: null` (e.g. for non-JS formats). + if (result && typeof result === "object" && result.source != null) { + return { + source: result.source, + shortCircuit: true, + format: "module", + }; + } + + // Fall back to the parent loader with the original (non-iitm) URL. + return nextLoad(deleteIitm(url), context); + } + + if (cjsInIitmChain.has(url)) { + const result = nextLoad(url, context); + if (result.format === "commonjs" && result.source != null) { + return { + format: result.format, + source: undefined, + }; + } + return result; + } + + return nextLoad(url, context); + } + + return { initialize, load, resolve, resolveSync, loadSync, applyOptions }; +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/hook.mjs b/js/src/auto-instrumentations/import-in-the-middle/hook.mjs new file mode 100644 index 000000000..008a4a116 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/hook.mjs @@ -0,0 +1,9 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. +// +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. + +import { createHook } from "./create-hook.mjs"; + +const { initialize, load, resolve } = createHook(import.meta); + +export { initialize, load, resolve }; diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.d.ts b/js/src/auto-instrumentations/import-in-the-middle/index.d.ts new file mode 100644 index 000000000..03364febe --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/index.d.ts @@ -0,0 +1,25 @@ +// Forked from import-in-the-middle@3.2.0. Modified by Braintrust. + +export type Namespace = { [key: string]: any }; +export type HookFn = ( + exported: Namespace, + name: string, + baseDir: string | void, +) => any; + +export declare class Hook { + constructor(modules: string[], hookFn: HookFn); + unhook(): void; +} + +export default Hook; + +type CreateAddHookMessageChannelReturn = { + addHookMessagePort: MessagePort; + waitForAllMessagesAcknowledged: () => Promise; + registerOptions: { data?: Data; transferList?: any[] }; +}; + +export declare function createAddHookMessageChannel< + Data = any, +>(): CreateAddHookMessageChannelReturn; diff --git a/js/src/auto-instrumentations/import-in-the-middle/index.js b/js/src/auto-instrumentations/import-in-the-middle/index.js new file mode 100644 index 000000000..26c5e47bb --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/index.js @@ -0,0 +1,193 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. +// +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. + +const moduleDetailsFromPath = require("module-details-from-path"); +const { isBuiltin } = require("module"); +const { fileURLToPath } = require("url"); +const { MessageChannel } = require("worker_threads"); + +const { + addHookedModules, + deleteHookedModules, + importHooks, + specifiers, + toHook, +} = require("./lib/register"); + +function addHook(hook) { + importHooks.push(hook); + toHook.forEach(([name, namespace, specifier]) => + hook(name, namespace, specifier), + ); +} + +function removeHook(hook) { + const index = importHooks.indexOf(hook); + if (index > -1) { + importHooks.splice(index, 1); + } +} + +function callHookFn(hookFn, namespace, name, baseDir) { + const newDefault = hookFn(namespace, name, baseDir); + if (newDefault && newDefault !== namespace && "default" in namespace) { + namespace.default = newDefault; + } +} + +function normalizeModules(modules) { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust import-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust import-in-the-middle only supports string module names or file URLs", + ); + } + } + + return modules; +} + +function moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) { + if (filePath && matchArg === filePath) { + return true; + } + + if (matchArg === specifier || matchArg === loadUrl || matchArg === name) { + return true; + } + + if (!baseDir) { + return false; + } + + // Keep the top-level package check from upstream, but do not support the + // broad internals mode. Internal files must be listed explicitly. + return matchArg === name && baseDir.endsWith(specifiers.get(loadUrl)); +} + +let sendModulesToLoader; + +function createAddHookMessageChannel() { + const { port1, port2 } = new MessageChannel(); + let pendingAckCount = 0; + let resolveFn; + + sendModulesToLoader = (modules) => { + pendingAckCount++; + port1.postMessage(modules); + }; + + port1 + .on("message", () => { + pendingAckCount--; + + if (resolveFn && pendingAckCount <= 0) { + resolveFn(); + } + }) + .unref(); + + function waitForAllMessagesAcknowledged() { + const timer = setInterval(() => {}, 1000); + const promise = new Promise((resolve) => { + resolveFn = resolve; + }).then(() => { + clearInterval(timer); + }); + + if (pendingAckCount === 0) { + resolveFn(); + } + + return promise; + } + + const addHookMessagePort = port2; + const registerOptions = { + data: { addHookMessagePort, include: [] }, + transferList: [addHookMessagePort], + }; + + return { + registerOptions, + addHookMessagePort, + waitForAllMessagesAcknowledged, + }; +} + +function Hook(modules, hookFn) { + if (this instanceof Hook === false) return new Hook(modules, hookFn); + + modules = normalizeModules(modules); + if (typeof hookFn !== "function") { + throw new TypeError( + "Braintrust import-in-the-middle requires a hook function", + ); + } + + addHookedModules(modules); + if (sendModulesToLoader) { + sendModulesToLoader(modules); + } + + this._modules = modules; + this._iitmHook = (name, namespace, specifier) => { + const loadUrl = name; + let filePath; + let baseDir; + + if (loadUrl.startsWith("node:")) { + const unprefixed = name.slice(5); + if (isBuiltin(unprefixed)) { + name = unprefixed; + } + } else if (loadUrl.startsWith("file://")) { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + try { + filePath = fileURLToPath(name); + name = filePath; + } catch (e) {} + Error.stackTraceLimit = stackTraceLimit; + + if (filePath) { + const details = moduleDetailsFromPath(filePath); + if (details) { + name = details.name; + baseDir = details.basedir; + } + } + } + + for (const matchArg of modules) { + if ( + moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) + ) { + callHookFn( + hookFn, + namespace, + filePath && matchArg === filePath ? filePath : matchArg, + baseDir, + ); + } + } + }; + + addHook(this._iitmHook); +} + +Hook.prototype.unhook = function () { + removeHook(this._iitmHook); + deleteHookedModules(this._modules); +}; + +module.exports = Hook; +module.exports.Hook = Hook; +module.exports.createAddHookMessageChannel = createAddHookMessageChannel; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs new file mode 100644 index 000000000..f01cfc6a6 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-esm-exports.mjs @@ -0,0 +1,116 @@ +"use strict"; + +import { Parser } from "acorn"; +import { importAttributesOrAssertions } from "acorn-import-attributes"; + +const acornOpts = { + ecmaVersion: "latest", + sourceType: "module", +}; + +const parser = Parser.extend(importAttributesOrAssertions); + +function warn(txt) { + process.emitWarning(txt, "get-esm-exports"); +} + +/** + * Utilizes an AST parser to interpret ESM source code and build a list of + * exported identifiers. In the baseline case, the list of identifiers will be + * the simple identifier names as written in the source code of the module. + * However, there is a special case: + * + * When an `export * from './foo.js'` line is encountered it is rewritten + * as `* from ./foo.js`. This allows the interpreting code to recognize a + * transitive export and recursively parse the indicated module. The returned + * identifier list will have "* from ./foo.js" as an item. + * + * @param {object} params + * @param {string} params.moduleSource The source code of the module to parse + * and interpret. + * + * @returns {Set} The identifiers exported by the module along with any + * custom directives. + */ +export default function getEsmExports(moduleSource) { + const exportedNames = new Set(); + const tree = parser.parse(moduleSource, acornOpts); + for (const node of tree.body) { + if (!node.type.startsWith("Export")) continue; + switch (node.type) { + case "ExportNamedDeclaration": + if (node.declaration) { + parseDeclaration(node, exportedNames); + } else { + parseSpecifiers(node, exportedNames); + } + break; + + case "ExportDefaultDeclaration": { + exportedNames.add("default"); + break; + } + + case "ExportAllDeclaration": + if (node.exported) { + exportedNames.add(node.exported.name); + } else { + exportedNames.add(`* from ${node.source.value}`); + } + break; + default: + warn("unrecognized export type: " + node.type); + } + } + return exportedNames; +} + +function parseDeclaration(node, exportedNames) { + switch (node.declaration.type) { + case "FunctionDeclaration": + exportedNames.add(node.declaration.id.name); + break; + case "VariableDeclaration": + for (const varDecl of node.declaration.declarations) { + parseVariableDeclaration(varDecl, exportedNames); + } + break; + case "ClassDeclaration": + exportedNames.add(node.declaration.id.name); + break; + default: + warn("unknown declaration type: " + node.delcaration.type); + } +} + +function parseVariableDeclaration(node, exportedNames) { + switch (node.id.type) { + case "Identifier": + exportedNames.add(node.id.name); + break; + case "ObjectPattern": + for (const prop of node.id.properties) { + exportedNames.add(prop.value.name); + } + break; + case "ArrayPattern": + for (const elem of node.id.elements) { + exportedNames.add(elem.name); + } + break; + default: + warn("unknown variable declaration type: " + node.id.type); + } +} + +function parseSpecifiers(node, exportedNames) { + for (const specifier of node.specifiers) { + if (specifier.exported.type === "Identifier") { + exportedNames.add(specifier.exported.name); + } else if (specifier.exported.type === "Literal") { + exportedNames.add(specifier.exported.value); + } else { + warn("unrecognized specifier type: " + specifier.exported.type); + } + } +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs new file mode 100644 index 000000000..70920ce77 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs @@ -0,0 +1,404 @@ +"use strict"; + +import getEsmExports from "./get-esm-exports.mjs"; +import { parse as parseCjs, initSync } from "cjs-module-lexer"; +import { readFileSync, existsSync } from "fs"; +import { builtinModules, createRequire } from "module"; +import { fileURLToPath, pathToFileURL } from "url"; +import { dirname, join } from "path"; +import { LOAD } from "./io.mjs"; + +const nodeMajor = Number(process.versions.node.split(".")[0]); +export const hasModuleExportsCJSDefault = nodeMajor >= 23; + +let parserInitialized = false; + +// The CJS export scanner is backed by WebAssembly. `initSync` compiles it +// up front so the scanner can run inside synchronous loader hooks +// (`module.registerHooks`) as well as the off-thread loader; it is a one-time +// cost on the first CommonJS module either way. +function ensureParserInitialized() { + if (!parserInitialized) { + initSync(); + parserInitialized = true; + } +} + +function addDefault(arr) { + return new Set(["default", ...arr]); +} + +function hasEsmSyntax(source) { + // Lightweight scan (no full parse) to determine if the *source code* + // contains ESM-specific syntax. This is used only when: + // - the loader chain didn't tell us a `format`, and + // - `getEsmExports()` found no exports. + // + // Notes: + // - We ignore comments and strings to reduce false positives. + // - We treat `import.meta` and static `import ...` as ESM. + // - We do NOT treat `import(` (dynamic import) as ESM because it is allowed + // in CJS as an expression. + if (source.indexOf("import") === -1) return false; + + const isIdentCharCode = (code) => + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 95 || // _ + code === 36; // $ + + const skipWhitespace = (idx) => { + while (idx < source.length) { + const c = source.charCodeAt(idx); + // space, tab, cr, lf + if (c !== 32 && c !== 9 && c !== 13 && c !== 10) break; + idx++; + } + return idx; + }; + + let i = 0; + while (i < source.length) { + const ch = source[i]; + + // Line comment + if (ch === "/" && source[i + 1] === "/") { + i += 2; + while (i < source.length && source[i] !== "\n") i++; + continue; + } + + // Block comment + if (ch === "/" && source[i + 1] === "*") { + i += 2; + while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) + i++; + i += 2; + continue; + } + + // Strings: '...' or "..." + if (ch === "'" || ch === '"') { + const quote = ch; + i++; + while (i < source.length) { + const c = source[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === quote) { + i++; + break; + } + i++; + } + continue; + } + + // Template strings: `...` + if (ch === "`") { + i++; + while (i < source.length) { + const c = source[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === "`") { + i++; + break; + } + i++; + } + continue; + } + + // Keyword scan (word-boundary): import + if (ch === "i") { + const prev = source.charCodeAt(i - 1); + if (i > 0 && isIdentCharCode(prev)) { + i++; + continue; + } + + if (source.startsWith("import", i)) { + const next = source.charCodeAt(i + 6); + if (isIdentCharCode(next)) { + i++; + continue; + } + + const j = skipWhitespace(i + 6); + // `import.meta` is ESM-only + if (source[j] === ".") return true; + // `import(` is dynamic import, allowed in CJS + if (source[j] === "(") { + i = j + 1; + continue; + } + // Otherwise assume it's a static import form + return true; + } + } + + i++; + } + + return false; +} + +// Cached exports for Node built-in modules +const BUILT_INS = new Map(); + +let require; + +// Returns a builtin's exports object. `process.getBuiltinModule` (Node >= +// 20.16 / >= 22.3) bypasses registered loader hooks; `require` does not. Under +// the in-thread `module.registerHooks` loader a plain `require(name)` here +// re-enters iitm's own hooks and resolves to the half-built wrapper instead of +// the native module. The off-thread `module.register` loader runs `require` on +// the loader thread where the hooks aren't installed, so the fallback stays +// correct on older Node that lacks getBuiltinModule. +function loadBuiltin(name) { + if (typeof process.getBuiltinModule === "function") { + return process.getBuiltinModule(name); + } + if (!require) { + require = createRequire(import.meta.url); + } + return require(name); +} + +function getExportsForNodeBuiltIn(name) { + let exports = BUILT_INS.get(name); + + if (!exports) { + // get all properties both enumerable and non-enumerable + exports = new Set( + addDefault(Object.getOwnPropertyNames(loadBuiltin(name))), + ); + // added in node 23 as alias for default in cjs modules + if (hasModuleExportsCJSDefault) { + exports.add("module.exports"); + } + BUILT_INS.set(name, exports); + } + + return exports; +} + +const urlsBeingProcessed = new Set(); // Guard against circular imports. + +/** + * This function looks for the package.json which contains the specifier trying to resolve. + * Once the package.json file has been found, we extract the file path from the specifier + * @param {string} specifier The specifier that is being search for inside the imports object + * @param {URL|string} fromUrl The url from which the search starts from + * @returns array with url and resolvedExport + */ +function resolvePackageImports(specifier, fromUrl) { + try { + const fromPath = fileURLToPath(fromUrl); + let currentDir = dirname(fromPath); + + // search for package.json file which has the real url to export + while (currentDir !== dirname(currentDir)) { + const packageJsonPath = join(currentDir, "package.json"); + + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (packageJson.imports && packageJson.imports[specifier]) { + const imports = packageJson.imports[specifier]; + + // Look for path inside packageJson + let resolvedExport; + if (imports && typeof imports === "object") { + const requireExport = imports.require; + const importExport = imports.import; + // look for the possibility of require and import which is standard for CJS/ESM + if (requireExport || importExport) { + // trying to resolve based on order of importance + resolvedExport = + requireExport.node || + requireExport.default || + importExport.node || + importExport.default; + } else if (imports.node || imports.default) { + resolvedExport = imports.node || imports.default; + } + } else if (typeof imports === "string") { + resolvedExport = imports; + } + + if (resolvedExport) { + const url = resolvedExport.startsWith(".") + ? pathToFileURL(join(currentDir, resolvedExport)) + : fromUrl; + return [url, resolvedExport]; + } + } + // return if we find a package.json but did not find an import + return null; + } + + currentDir = dirname(currentDir); + } + } catch (cause) { + throw Error(`Failed to find export: ${specifier}`, { cause }); + } + return null; +} + +function* getCjsExports(url, context, source) { + if (urlsBeingProcessed.has(url)) { + return new Set(); + } + urlsBeingProcessed.add(url); + + try { + ensureParserInitialized(); + const result = parseCjs(source); + const full = addDefault(result.exports); + + for (const reexport of result.reexports) { + if (reexport.startsWith("node:") || builtinModules.includes(reexport)) { + for (const each of getExportsForNodeBuiltIn(reexport)) { + full.add(each); + } + continue; + } + + // Resolve each re-export relative to the current module. Keep the + // resolution scoped to this iteration: a `#`-import rewrites both the + // base URL and the specifier, and that rewrite must not leak into the + // next re-export. + let reUrl = url; + let reSpecifier = reexport === "." ? "./" : reexport; + + // Entries in the import field should always start with # + if (reSpecifier.startsWith("#")) { + const resolved = resolvePackageImports(reSpecifier, url); + if (!resolved) continue; + [reUrl, reSpecifier] = resolved; + } + + if (!require) { + require = createRequire(import.meta.url); + } + const newUrl = pathToFileURL( + require.resolve(reSpecifier, { + paths: [dirname(fileURLToPath(reUrl))], + }), + ).href; + + if (newUrl.endsWith(".node") || newUrl.endsWith(".json")) { + continue; + } + + for (const each of yield* getExports(newUrl, context)) { + full.add(each); + } + } + + // added in node 23 as alias for default in cjs modules + if (full.has("default") && hasModuleExportsCJSDefault) { + full.add("module.exports"); + } + + // we know that it's commonjs at this point, because ESM failed + context.format = "commonjs"; + return full; + } finally { + urlsBeingProcessed.delete(url); + } +} + +/** + * Inspects a module for its type (commonjs or module), obtains the source code + * for said module from the loader API, and parses the result for the entities + * exported from that module. + * + * This is a "sans-io" generator: instead of calling the loader's `load` hook + * directly, it `yield`s `[LOAD, url, context]` and is driven by either + * {@link driveSync} or {@link driveAsync} (see `lib/io.mjs`). The same body + * therefore serves both the off-thread loader and `module.registerHooks`. + * + * @param {string} url A file URL string pointing to the module that we should + * get the exports of. + * @param {object} context Context object as provided by the `load` hook from + * the loaders API. + * + * @returns {Generator>} A generator that yields I/O + * operations and ultimately returns the identifiers exported by the module. + * Please see {@link getEsmExports} for caveats on special identifiers that may + * be included in the result set. + */ +export function* getExports(url, context) { + // `[LOAD, ...]` gives us the possibility of getting the source from an + // upstream loader. This doesn't always work though, so later on we fall back + // to reading it from disk. + const parentCtx = yield [LOAD, url, context]; + let source = parentCtx.source; + const format = parentCtx.format; + + // Loader hooks can return ArrayBuffer / TypedArray sources. Normalize to a + // string for parsing. + if (source && typeof source !== "string") { + // Avoid copies where possible: + // - Buffer.from(Uint8Array) copies + // - Buffer.from(ArrayBuffer, offset, length) wraps the existing memory + if (Buffer.isBuffer(source)) { + source = source.toString("utf8"); + } else if (ArrayBuffer.isView(source)) { + source = Buffer.from( + source.buffer, + source.byteOffset, + source.byteLength, + ).toString("utf8"); + } else { + source = Buffer.from(source).toString("utf8"); + } + } + + if (!source) { + if (format === "builtin") { + // Builtins don't give us the source property, so we're stuck + // just requiring it to get the exports. + return getExportsForNodeBuiltIn(url); + } + + // Sometimes source is retrieved by parentLoad, CommonJs isn't. + source = readFileSync(fileURLToPath(url), "utf8"); + } + + try { + if (format === "module") { + return getEsmExports(source); + } + + if (format === "commonjs") { + return yield* getCjsExports(url, context, source); + } + + // At this point our `format` is either undefined or not known by us. Fall + // back to parsing as ESM/CJS. + const esmExports = getEsmExports(source); + if (!esmExports.size) { + // If there's strong evidence this is ESM (static import/import.meta), + // prefer returning the empty ESM export set over incorrectly treating it + // as CJS. + if (!hasEsmSyntax(source)) { + // It might be possible to get here if the format + // isn't set at first and yet we have an ESM module with no exports. + return yield* getCjsExports(url, context, source); + } + } + return esmExports; + } catch (cause) { + const err = new Error(`Failed to parse '${url}'`); + err.cause = cause; + throw err; + } +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs new file mode 100644 index 000000000..aac1f3fb0 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/io.mjs @@ -0,0 +1,78 @@ +"use strict"; + +// The export-collection logic (resolving star re-exports, reading source, +// parsing exports) is identical whether `import-in-the-middle` runs as an +// off-thread loader (`module.register`, asynchronous `nextResolve`/`nextLoad`) +// or as an in-thread synchronous loader (`module.registerHooks`). To keep a +// single implementation of that logic — instead of two copies that drift — it +// is written as "sans-io" generators that `yield` the I/O they need and let a +// driver fulfil it. The async driver awaits; the sync driver calls straight +// through. Everything between the yields is shared. + +// Operation kinds a loader generator may yield. Each is `[KIND, ...args]`. +export const LOAD = 0; // [LOAD, url, context] -> resolves to { source, format } +export const RESOLVE = 1; // [RESOLVE, specifier, context] -> resolves to { url, format } + +function runOp(op, io) { + if (op[0] === RESOLVE) { + return io.resolve(op[1], op[2]); + } + return io.load(op[1], op[2]); +} + +/** + * Drives a loader generator to completion, fulfilling each yielded I/O + * operation synchronously. Used with `module.registerHooks`, whose + * `nextResolve`/`nextLoad` return their result directly. + * + * Errors from I/O are thrown back into the generator (via `gen.throw`) so its + * `try`/`finally` blocks run exactly as they would for an `await` rejection. + * + * @template T + * @param {Generator} gen + * @param {{ load: Function, resolve?: Function }} io + * @returns {T} + */ +export function driveSync(gen, io) { + let next = gen.next(); + while (next.done === false) { + let result; + let error; + let threw = false; + try { + result = runOp(next.value, io); + } catch (err) { + threw = true; + error = err; + } + next = threw ? gen.throw(error) : gen.next(result); + } + return next.value; +} + +/** + * Drives a loader generator to completion, awaiting each yielded I/O + * operation. Used with the off-thread `module.register` loader, whose + * `nextResolve`/`nextLoad` are asynchronous. + * + * @template T + * @param {Generator} gen + * @param {{ load: Function, resolve?: Function }} io + * @returns {Promise} + */ +export async function driveAsync(gen, io) { + let next = gen.next(); + while (next.done === false) { + let result; + let error; + let threw = false; + try { + result = await runOp(next.value, io); + } catch (err) { + threw = true; + error = err; + } + next = threw ? gen.throw(error) : gen.next(result); + } + return next.value; +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.js b/js/src/auto-instrumentations/import-in-the-middle/lib/register.js new file mode 100644 index 000000000..08eecf643 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.js @@ -0,0 +1,88 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2.0 License. +// +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. + +const importHooks = []; // TODO should this be a Set? +const setters = new WeakMap(); +const getters = new WeakMap(); +const specifiers = new Map(); +const toHook = []; +const hookedModuleCounts = new Map(); +const hookedModules = new Set(); + +const proxyHandler = { + set(target, name, value) { + const set = setters.get(target); + const setter = set && set[name]; + if (typeof setter === "function") { + return setter(value); + } + // If a module doesn't export the property being assigned (e.g. no default + // export), there is no setter to call. Don't crash userland code. + return true; + }, + + get(target, name) { + if (name === Symbol.toStringTag) { + return "Module"; + } + + const getter = getters.get(target)[name]; + + if (typeof getter === "function") { + return getter(); + } + }, + + defineProperty(target, property, descriptor) { + if (!("value" in descriptor)) { + throw new Error( + "Getters/setters are not supported for exports property descriptors.", + ); + } + + const set = setters.get(target); + const setter = set && set[property]; + if (typeof setter === "function") { + return setter(descriptor.value); + } + return true; + }, +}; + +function register(name, namespace, set, get, specifier) { + specifiers.set(name, specifier); + setters.set(namespace, set); + getters.set(namespace, get); + const proxy = new Proxy(namespace, proxyHandler); + importHooks.forEach((hook) => hook(name, proxy, specifier)); + toHook.push([name, proxy, specifier]); +} + +function addHookedModules(modules) { + for (const each of modules) { + const nextCount = (hookedModuleCounts.get(each) || 0) + 1; + hookedModuleCounts.set(each, nextCount); + hookedModules.add(each); + } +} + +function deleteHookedModules(modules) { + for (const each of modules) { + const nextCount = (hookedModuleCounts.get(each) || 0) - 1; + if (nextCount > 0) { + hookedModuleCounts.set(each, nextCount); + } else { + hookedModuleCounts.delete(each); + hookedModules.delete(each); + } + } +} + +exports.register = register; +exports.addHookedModules = addHookedModules; +exports.deleteHookedModules = deleteHookedModules; +exports.hookedModules = hookedModules; +exports.importHooks = importHooks; +exports.specifiers = specifiers; +exports.toHook = toHook; diff --git a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts new file mode 100644 index 000000000..64976895a --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.d.ts @@ -0,0 +1,4 @@ +// Forked from import-in-the-middle@3.2.0. Modified by Braintrust. + +export declare function supportsSyncHooks(): boolean; +export declare function register(): void; diff --git a/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs new file mode 100644 index 000000000..d1e96b9af --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/register-hooks.mjs @@ -0,0 +1,50 @@ +import * as module from "module"; +import { createHook, supportsSyncHooks } from "./create-hook.mjs"; + +export { supportsSyncHooks }; + +const hook = createHook(import.meta); + +let registered = false; + +/** + * Registers `import-in-the-middle` as a *synchronous*, in-thread loader hook via + * [`module.registerHooks()`](https://nodejs.org/api/module.html#moduleregisterhooksoptions). + * + * Unlike `module.register('import-in-the-middle/hook.mjs', ...)`, which runs the + * loader on a separate thread and pays an IPC round-trip per resolved module, + * synchronous hooks run in the application thread. There is no message channel + * to bridge, so `Hook()` registrations from the main `import-in-the-middle` + * entry point are visible to the loader directly and no acknowledgement step is + * required. + * + * Requires a Node.js version whose `module.registerHooks` accepts the nullish + * CommonJS source the loader relies on: >= 22.22.3, >= 24.11.1, >= 25.1.0, or + * >= 26.0.0 (see `supportsSyncHooks`). Use that predicate to fall back to the + * asynchronous `module.register` loader on unsupported versions. + * + * Braintrust's fork only intercepts modules registered through `Hook([...])`. + * Call `Hook()` before importing the modules you want to wrap. + * + * @returns {void} + */ +export function register() { + if (!supportsSyncHooks()) { + throw new Error( + "'import-in-the-middle' synchronous hooks require a Node.js version whose " + + "module.registerHooks accepts nullish CommonJS source " + + "(>= 22.22.3, >= 24.11.1, >= 25.1.0, or >= 26.0.0); " + + "see https://github.com/nodejs/node/pull/59929", + ); + } + + if (registered) { + process.emitWarning( + "'import-in-the-middle' synchronous hooks have already been registered", + ); + return; + } + registered = true; + + module.registerHooks({ resolve: hook.resolveSync, load: hook.loadSync }); +} diff --git a/js/src/auto-instrumentations/orchestrion-js/index.ts b/js/src/auto-instrumentations/orchestrion-js/index.ts index 831fb0e2f..0192c0d9c 100644 --- a/js/src/auto-instrumentations/orchestrion-js/index.ts +++ b/js/src/auto-instrumentations/orchestrion-js/index.ts @@ -16,4 +16,4 @@ export function create( return new InstrumentationMatcher(configs, dcModule); } -export type { InstrumentationConfig } from "./types"; +export type { InstrumentationConfig, ModuleType } from "./types"; diff --git a/js/src/auto-instrumentations/require-in-the-middle/index.js b/js/src/auto-instrumentations/require-in-the-middle/index.js new file mode 100644 index 000000000..3888f5d51 --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/index.js @@ -0,0 +1,252 @@ +"use strict"; + +const path = require("path"); +const Module = require("module"); +const moduleDetailsFromPath = require("module-details-from-path"); + +module.exports = Hook; +module.exports.Hook = Hook; + +let builtinModules; + +let isCore; +if (Module.isBuiltin) { + isCore = Module.isBuiltin; +} else if (Module.builtinModules) { + isCore = (moduleName) => { + if (moduleName.startsWith("node:")) { + return true; + } + + if (builtinModules === undefined) { + builtinModules = new Set(Module.builtinModules); + } + + return builtinModules.has(moduleName); + }; +} else { + throw new Error( + "Braintrust require-in-the-middle requires Node.js >=v9.3.0 or >=v8.10.0", + ); +} + +const normalize = /([/\\]index)?(\.js|\.cjs)?$/; + +class ExportsCache { + constructor() { + this._localCache = new Map(); + this._kRitmExports = Symbol("RitmExports"); + } + + has(filename, isBuiltin) { + if (this._localCache.has(filename)) { + return true; + } else if (!isBuiltin) { + const mod = require.cache[filename]; + return !!(mod && this._kRitmExports in mod); + } else { + return false; + } + } + + get(filename, isBuiltin) { + const cachedExports = this._localCache.get(filename); + if (cachedExports !== undefined) { + return cachedExports; + } else if (!isBuiltin) { + const mod = require.cache[filename]; + return mod && mod[this._kRitmExports]; + } + } + + set(filename, exports, isBuiltin) { + if (isBuiltin) { + this._localCache.set(filename, exports); + } else if (filename in require.cache) { + require.cache[filename][this._kRitmExports] = exports; + } else { + this._localCache.set(filename, exports); + } + } +} + +function normalizeModules(modules) { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust require-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust require-in-the-middle only supports string module names or absolute paths", + ); + } + } + + return modules; +} + +function Hook(modules, onrequire) { + if (this instanceof Hook === false) return new Hook(modules, onrequire); + + modules = normalizeModules(modules); + if (typeof onrequire !== "function") { + throw new TypeError( + "Braintrust require-in-the-middle requires an onrequire function", + ); + } + + if (typeof Module._resolveFilename !== "function") { + throw new Error( + `Expected Module._resolveFilename to be a function, got ${typeof Module._resolveFilename}`, + ); + } + + this._cache = new ExportsCache(); + this._unhooked = false; + this._origRequire = Module.prototype.require; + + const self = this; + const patching = new Set(); + + this._require = Module.prototype.require = function (id) { + if (self._unhooked === true) { + return self._origRequire.apply(this, arguments); + } + + return patchedRequire.call(this, arguments, false); + }; + + if (typeof process.getBuiltinModule === "function") { + this._origGetBuiltinModule = process.getBuiltinModule; + this._getBuiltinModule = process.getBuiltinModule = function (id) { + if (self._unhooked === true) { + return self._origGetBuiltinModule.apply(this, arguments); + } + + return patchedRequire.call(this, arguments, true); + }; + } + + function patchedRequire(args, coreOnly) { + const id = args[0]; + const core = isCore(id); + let filename; + if (core) { + filename = id; + if (id.startsWith("node:")) { + const idWithoutPrefix = id.slice(5); + if (isCore(idWithoutPrefix)) { + filename = idWithoutPrefix; + } + } + } else if (coreOnly) { + return self._origGetBuiltinModule.apply(this, args); + } else { + try { + filename = Module._resolveFilename(id, this); + } catch (resolveErr) { + return self._origRequire.apply(this, args); + } + } + + if (self._cache.has(filename, core) === true) { + return self._cache.get(filename, core); + } + + const isPatching = patching.has(filename); + if (isPatching === false) { + patching.add(filename); + } + + const exports = coreOnly + ? self._origGetBuiltinModule.apply(this, args) + : self._origRequire.apply(this, args); + + if (isPatching === true) { + return exports; + } + + patching.delete(filename); + + let moduleName; + let basedir; + + if (core === true) { + if (modules.includes(filename) === false) { + return exports; + } + moduleName = filename; + } else if (modules.includes(filename)) { + const parsedPath = path.parse(filename); + moduleName = parsedPath.name; + basedir = parsedPath.dir; + } else { + const stat = moduleDetailsFromPath(filename); + if (stat === undefined) { + return exports; + } + moduleName = stat.name; + basedir = stat.basedir; + + const fullModuleName = resolveModuleName(stat); + let matchFound = false; + if (!id.startsWith(".") && modules.includes(id)) { + moduleName = id; + matchFound = true; + } + + if (!modules.includes(moduleName) && !modules.includes(fullModuleName)) { + return exports; + } + + if (modules.includes(fullModuleName) && fullModuleName !== moduleName) { + moduleName = fullModuleName; + matchFound = true; + } + + if (!matchFound) { + let res; + try { + res = require.resolve(moduleName, { paths: [basedir] }); + } catch (e) { + self._cache.set(filename, exports, core); + return exports; + } + + if (res !== filename) { + self._cache.set(filename, exports, core); + return exports; + } + } + } + + self._cache.set(filename, exports, core); + const patchedExports = onrequire(exports, moduleName, basedir); + self._cache.set(filename, patchedExports, core); + + return patchedExports; + } +} + +Hook.prototype.unhook = function () { + this._unhooked = true; + + if (this._require === Module.prototype.require) { + Module.prototype.require = this._origRequire; + } + + if (process.getBuiltinModule !== undefined) { + if (this._getBuiltinModule === process.getBuiltinModule) { + process.getBuiltinModule = this._origGetBuiltinModule; + } + } +}; + +function resolveModuleName(stat) { + const normalizedPath = + path.sep !== "/" ? stat.path.split(path.sep).join("/") : stat.path; + return path.posix.join(stat.name, normalizedPath).replace(normalize, ""); +} diff --git a/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts b/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts new file mode 100644 index 000000000..7c7658f9e --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/types/index.d.ts @@ -0,0 +1,10 @@ +export interface HookOptions { + internals?: boolean; +} + +export type OnRequireFn = (exports: T, name: string, basedir?: string) => T; + +export class Hook { + constructor(modules: string[], onrequire: OnRequireFn); + unhook(): void; +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/.gitignore b/js/tests/auto-instrumentations/fixtures/vendor-hooks/.gitignore new file mode 100644 index 000000000..af1ce8e85 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/.gitignore @@ -0,0 +1,2 @@ +!node_modules/ +!node_modules/** diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs new file mode 100644 index 000000000..e6d69f304 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert"; +import getLabel, { foo } from "hook-target"; +import getUnhookedLabel, { foo as unhookedFoo } from "unhooked-target"; +import cjsTarget from "cjs-hook-target"; + +assert.equal(foo, 57); +assert.equal(getLabel(), "patched"); +assert.equal(unhookedFoo, 10); +assert.equal(getUnhookedLabel(), "untouched"); +assert.equal(cjsTarget.value, 8); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs new file mode 100644 index 000000000..ee2eabea5 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert"; +import { register } from "node:module"; +import { fileURLToPath } from "node:url"; +import { + Hook, + createAddHookMessageChannel, +} from "../../../../src/auto-instrumentations/import-in-the-middle/index.js"; + +const hookUrl = new URL( + "../../../../src/auto-instrumentations/import-in-the-middle/hook.mjs", + import.meta.url, +); +const { addHookMessagePort, registerOptions, waitForAllMessagesAcknowledged } = + createAddHookMessageChannel(); + +register(fileURLToPath(hookUrl), import.meta.url, registerOptions); + +globalThis.__braintrustIitmAsyncHookCalls = 0; +const hook = new Hook(["hook-target", "cjs-hook-target"], (exports, name) => { + globalThis.__braintrustIitmAsyncHookCalls++; + if (name === "hook-target") { + exports.foo += 15; + exports.default = () => "patched"; + } + if (name === "cjs-hook-target") { + exports.default.value = 8; + } +}); + +await waitForAllMessagesAcknowledged(); + +process.on("exit", () => { + assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 2); + hook.unhook(); + addHookMessagePort.close(); +}); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs new file mode 100644 index 000000000..9f4bb0012 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert"; +import { createRequire } from "node:module"; +import { Hook } from "../../../../src/auto-instrumentations/import-in-the-middle/index.js"; +import { + register, + supportsSyncHooks, +} from "../../../../src/auto-instrumentations/import-in-the-middle/register-hooks.mjs"; + +if (!supportsSyncHooks()) { + process.exit(0); +} + +register(); + +let calls = 0; +const hook = new Hook( + ["hook-target", "cjs-hook-target", "fs"], + (exports, name) => { + calls++; + if (name === "hook-target") { + exports.foo += 15; + exports.default = () => "patched"; + } + if (name === "cjs-hook-target") { + exports.default.value = 8; + } + if (name === "fs") { + exports.existsSync = () => true; + } + }, +); + +const target = await import("hook-target"); +const other = await import("unhooked-target"); +const cjsTarget = await import("cjs-hook-target"); +const fs = await import("node:fs"); + +assert.equal(target.foo, 57); +assert.equal(target.default(), "patched"); +assert.equal(other.foo, 10); +assert.equal(other.default(), "untouched"); +assert.equal(cjsTarget.default.value, 8); +assert.equal(fs.existsSync("/definitely/not/a/real/path"), true); + +const require = createRequire(import.meta.url); +const requiredFs = require("fs"); +assert.equal(Object.isExtensible(requiredFs), true); + +assert.equal(calls, 3); +hook.unhook(); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/index.cjs new file mode 100644 index 000000000..c874cefb1 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/index.cjs @@ -0,0 +1,3 @@ +module.exports = { + value: 2, +}; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/package.json new file mode 100644 index 000000000..225b6673a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-hook-target/package.json @@ -0,0 +1,8 @@ +{ + "name": "cjs-hook-target", + "version": "1.0.0", + "main": "./index.cjs", + "exports": { + ".": "./index.cjs" + } +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/index.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/index.mjs new file mode 100644 index 000000000..294d98f44 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/index.mjs @@ -0,0 +1,4 @@ +export let foo = 42; +export default function getLabel() { + return "original"; +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/package.json new file mode 100644 index 000000000..afd924186 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/hook-target/package.json @@ -0,0 +1,8 @@ +{ + "name": "hook-target", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./index.mjs" + } +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/index.js b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/index.js new file mode 100644 index 000000000..9e3420b89 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/index.js @@ -0,0 +1,3 @@ +module.exports = { + value: 5, +}; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/package.json new file mode 100644 index 000000000..ad192e043 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-other/package.json @@ -0,0 +1,5 @@ +{ + "name": "ritm-other", + "version": "1.0.0", + "main": "./index.js" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/index.js b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/index.js new file mode 100644 index 000000000..13c565264 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/index.js @@ -0,0 +1,3 @@ +module.exports = { + value: 1, +}; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/package.json new file mode 100644 index 000000000..2ffcdff3a --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/ritm-target/package.json @@ -0,0 +1,5 @@ +{ + "name": "ritm-target", + "version": "1.0.0", + "main": "./index.js" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/index.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/index.mjs new file mode 100644 index 000000000..9d66f82ad --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/index.mjs @@ -0,0 +1,4 @@ +export const foo = 10; +export default function getLabel() { + return "untouched"; +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/package.json new file mode 100644 index 000000000..4275622fd --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/unhooked-target/package.json @@ -0,0 +1,8 @@ +{ + "name": "unhooked-target", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./index.mjs" + } +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs new file mode 100644 index 000000000..f3ff3de96 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/ritm-app.cjs @@ -0,0 +1,38 @@ +const assert = require("node:assert"); +const { + Hook, +} = require("../../../../src/auto-instrumentations/require-in-the-middle"); + +let calls = 0; +const hook = new Hook(["ritm-target"], (exports, name) => { + calls++; + assert.equal(name, "ritm-target"); + exports.value += 10; + return exports; +}); + +assert.equal(require("ritm-target").value, 11); +assert.equal(require("ritm-target").value, 11); +assert.equal(calls, 1); +assert.equal(require("ritm-other").value, 5); + +hook.unhook(); +delete require.cache[require.resolve("ritm-target")]; +assert.equal(require("ritm-target").value, 1); + +let builtinCalls = 0; +const builtinHook = new Hook(["path"], (exports, name) => { + builtinCalls++; + assert.equal(name, "path"); + return { ...exports, join: () => "patched" }; +}); + +assert.equal(require("node:path").join("a", "b"), "patched"); +assert.equal(require("path").join("a", "b"), "patched"); + +if (typeof process.getBuiltinModule === "function") { + assert.equal(process.getBuiltinModule("path").join("a", "b"), "patched"); +} + +assert.equal(builtinCalls, 1); +builtinHook.unhook(); diff --git a/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts b/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts new file mode 100644 index 000000000..f1bac474f --- /dev/null +++ b/js/tests/auto-instrumentations/import-require-in-the-middle.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures", "vendor-hooks"); + +describe("vendored import-in-the-middle and require-in-the-middle", () => { + it("only wraps explicitly hooked ESM imports through the async loader", async () => { + await runNode({ + args: [ + "--import", + path.join(fixturesDir, "iitm-async-setup.mjs"), + path.join(fixturesDir, "iitm-async-app.mjs"), + ], + cwd: fixturesDir, + }); + }); + + it("only wraps explicitly hooked ESM imports through sync registerHooks", async () => { + await runNode({ + args: [path.join(fixturesDir, "iitm-sync-app.mjs")], + cwd: fixturesDir, + }); + }); + + it("only wraps explicitly hooked CommonJS requires", async () => { + await runNode({ + args: [path.join(fixturesDir, "ritm-app.cjs")], + cwd: fixturesDir, + }); + }); +}); + +function runNode({ + args, + cwd, +}: { + args: string[]; + cwd: string; +}): Promise { + const normalizedArgs = + process.platform === "win32" + ? args.map((arg, index) => { + if (index > 0 && args[index - 1] === "--import") { + return pathToFileURL(path.resolve(arg)).href; + } + return arg; + }) + : args; + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, normalizedArgs, { + cwd, + env: { ...process.env, NODE_OPTIONS: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Node exited with code ${code ?? "null"}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`, + ), + ); + } + }); + }).then(() => { + expect(true).toBe(true); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ed3276e8..758a7e012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,12 @@ importers: '@vercel/functions': specifier: ^1.0.2 version: 1.0.2 + acorn: + specifier: ^8.16.0 + version: 8.16.0 + acorn-import-attributes: + specifier: ^1.9.5 + version: 1.9.5(acorn@8.16.0) ajv: specifier: ^8.20.0 version: 8.20.0 @@ -350,6 +356,9 @@ importers: astring: specifier: ^1.9.0 version: 1.9.0 + cjs-module-lexer: + specifier: ^2.2.0 + version: 2.2.0 cli-progress: specifier: ^3.12.0 version: 3.12.0 @@ -2000,6 +2009,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -2216,6 +2230,9 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -5790,6 +5807,10 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5979,6 +6000,8 @@ snapshots: chrome-trace-event@1.0.4: {} + cjs-module-lexer@2.2.0: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 From 3929e67ef9aca984ada8dd069e53bca31c284720 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 23 Jun 2026 14:22:12 +0200 Subject: [PATCH 4/7] cs --- .changeset/large-gifts-go.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/large-gifts-go.md diff --git a/.changeset/large-gifts-go.md b/.changeset/large-gifts-go.md new file mode 100644 index 000000000..00b3dd5ff --- /dev/null +++ b/.changeset/large-gifts-go.md @@ -0,0 +1,5 @@ +--- +"braintrust": patch +--- + +ref: Fork import-in-the-middle and require-in-the-middle From 2e03df6b697e0c697740ff9939f01e74cfc2db87 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 23 Jun 2026 15:21:25 +0200 Subject: [PATCH 5/7] fix? --- js/src/auto-instrumentations/orchestrion-js/matcher.ts | 4 ++++ js/src/auto-instrumentations/orchestrion-js/transformer.ts | 2 ++ js/tsconfig.json | 6 ++++++ js/vitest.config.js | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/js/src/auto-instrumentations/orchestrion-js/matcher.ts b/js/src/auto-instrumentations/orchestrion-js/matcher.ts index cbb755f71..66a61f94c 100644 --- a/js/src/auto-instrumentations/orchestrion-js/matcher.ts +++ b/js/src/auto-instrumentations/orchestrion-js/matcher.ts @@ -59,4 +59,8 @@ export class InstrumentationMatcher { return this.transformers[id]; } + + free(): void { + this.transformers = {}; + } } diff --git a/js/src/auto-instrumentations/orchestrion-js/transformer.ts b/js/src/auto-instrumentations/orchestrion-js/transformer.ts index 54fc0a6f5..ecd0d66f3 100644 --- a/js/src/auto-instrumentations/orchestrion-js/transformer.ts +++ b/js/src/auto-instrumentations/orchestrion-js/transformer.ts @@ -143,6 +143,8 @@ export class Transformer { return { code }; } + free(): void {} + private visit(state: TransformState, ...args: any[]): void { const transform = transforms[state.operator]; const { index = 0 } = state.functionQuery as any; diff --git a/js/tsconfig.json b/js/tsconfig.json index 24b85b11b..3be431096 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -5,6 +5,12 @@ "module": "commonjs", "target": "es2022", "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "@apm-js-collab/code-transformer": [ + "src/auto-instrumentations/orchestrion-js/index.ts" + ] + }, "strict": true, "esModuleInterop": true, "skipLibCheck": true diff --git a/js/vitest.config.js b/js/vitest.config.js index d9c34decb..7d680a0a5 100644 --- a/js/vitest.config.js +++ b/js/vitest.config.js @@ -13,6 +13,10 @@ const config = { resolve: { extensions: [".ts", ".tsx", ".mts", ".js", ".mjs", ".jsx", ".json"], alias: { + "@apm-js-collab/code-transformer": path.resolve( + __dirname, + "src/auto-instrumentations/orchestrion-js/index.ts", + ), // Prevent resolution into vendor directories vendor: false, }, From e4ef426e12d89c26e724236b7ac6d093d5ae632c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 17:41:16 +0200 Subject: [PATCH 6/7] ref: Use top-level export patching for Mastra --- .changeset/hot-boats-fail.md | 2 - js/package.json | 2 +- ...copy-auto-instrumentation-vendor-hooks.mjs | 16 + .../auto-instrumentations/bundler/plugin.ts | 92 +++- .../bundler/webpack-loader.ts | 70 ++- js/src/auto-instrumentations/configs/all.ts | 9 +- js/src/auto-instrumentations/hook.mts | 18 + .../loader/mastra-observability-patch.test.ts | 253 ++++++---- .../loader/mastra-observability-patch.ts | 451 +++++++++-------- .../loader/special-case-patches.ts | 27 +- .../loader/top-level-export-patches-node.ts | 75 +++ .../loader/top-level-export-patches.test.ts | 185 +++++++ .../loader/top-level-export-patches.ts | 456 ++++++++++++++++++ js/src/instrumentation/braintrust-plugin.ts | 5 +- .../node/apply-auto-instrumentation-entry.ts | 32 ++ js/src/node/config.ts | 20 +- js/src/wrappers/mastra.ts | 8 +- .../node_modules/@mastra/core/package.json | 15 + .../@mastra/observability/package.json | 11 + ...untime-apply-auto-mastra-top-level-esm.mjs | 3 + .../fixtures/test-mastra-bundler.js | 3 + .../fixtures/test-mastra-top-level-cjs.cjs | 14 + .../fixtures/test-mastra-top-level-esm.mjs | 14 + .../auto-instrumentations/loader-hook.test.ts | 69 +++ .../transformation.test.ts | 98 ++++ 25 files changed, 1556 insertions(+), 392 deletions(-) create mode 100644 js/scripts/copy-auto-instrumentation-vendor-hooks.mjs create mode 100644 js/src/auto-instrumentations/loader/top-level-export-patches-node.ts create mode 100644 js/src/auto-instrumentations/loader/top-level-export-patches.test.ts create mode 100644 js/src/auto-instrumentations/loader/top-level-export-patches.ts create mode 100644 js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js create mode 100644 js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs diff --git a/.changeset/hot-boats-fail.md b/.changeset/hot-boats-fail.md index dec90ee29..732686c14 100644 --- a/.changeset/hot-boats-fail.md +++ b/.changeset/hot-boats-fail.md @@ -2,6 +2,4 @@ "braintrust": patch --- -ref: Vendor orchestrion-js - ref: Fork `orchestrion-js`, `import-in-the-middle` and `require-in-the-middle` diff --git a/js/package.json b/js/package.json index bf669af17..bafe65d1f 100644 --- a/js/package.json +++ b/js/package.json @@ -143,7 +143,7 @@ ], "scripts": { "postinstall": "node ./scripts/install.js", - "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup", + "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup && node ./scripts/copy-auto-instrumentation-vendor-hooks.mjs", "check:typings": "tsc --noEmit", "watch": "tsup --watch", "clean": "rm -r dist/* && rm -r dev/dist/* && rm -r util/dist/*", diff --git a/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs b/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs new file mode 100644 index 000000000..bba72f3ff --- /dev/null +++ b/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs @@ -0,0 +1,16 @@ +import { cp, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const jsRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const pairs = [ + ["import-in-the-middle", "import-in-the-middle"], + ["require-in-the-middle", "require-in-the-middle"], +]; + +for (const [sourceName, destName] of pairs) { + const source = join(jsRoot, "src", "auto-instrumentations", sourceName); + const dest = join(jsRoot, "dist", "auto-instrumentations", destName); + await rm(dest, { force: true, recursive: true }); + await cp(source, dest, { recursive: true }); +} diff --git a/js/src/auto-instrumentations/bundler/plugin.ts b/js/src/auto-instrumentations/bundler/plugin.ts index dda1b7490..c1d31d451 100644 --- a/js/src/auto-instrumentations/bundler/plugin.ts +++ b/js/src/auto-instrumentations/bundler/plugin.ts @@ -1,11 +1,20 @@ import { createUnplugin } from "unplugin"; import { create, type InstrumentationConfig } from "../orchestrion-js"; -import { extname, join, sep } from "path"; +import { dirname, extname, join, resolve, sep } from "path"; import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import moduleDetailsFromPath from "module-details-from-path"; import { getDefaultInstrumentationConfigs } from "../configs/all"; import { applySpecialCasePatch } from "../loader/special-case-patches"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + type TopLevelImportHookTarget, +} from "../loader/top-level-export-patches"; +import { readDisabledInstrumentationEnvConfig } from "../../instrumentation/config"; + +const TOP_LEVEL_ORIGINAL_IMPORT_PREFIX = "braintrust-top-level-original:"; +const TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX = `\0${TOP_LEVEL_ORIGINAL_IMPORT_PREFIX}`; export interface LegacyBundlerPluginOptions { /** @@ -75,6 +84,18 @@ export const unplugin = createUnplugin( const allInstrumentations = getDefaultInstrumentationConfigs({ additionalInstrumentations: options.instrumentations, }); + const disabledIntegrationConfig = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + const topLevelImportHookTarget: TopLevelImportHookTarget = + options.browser === true ? "browser" : "node"; + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target: topLevelImportHookTarget, + }); + const originalSources = new Map(); + const originalDirectories = new Map(); + let nextOriginalId = 0; // Default to browser build, use polyfill unless explicitly disabled const dcModule = options.browser === false ? undefined : "dc-browser"; @@ -85,11 +106,37 @@ export const unplugin = createUnplugin( return { name: "code-transformer", enforce: "pre", + resolveId(id: string, importer?: string) { + if (id.startsWith(TOP_LEVEL_ORIGINAL_IMPORT_PREFIX)) { + return `${TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX}${id.slice( + TOP_LEVEL_ORIGINAL_IMPORT_PREFIX.length, + )}`; + } + if ( + id.startsWith(".") && + importer?.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX) + ) { + const originalDirectory = originalDirectories.get(importer); + if (originalDirectory) { + return resolve(originalDirectory, id); + } + } + return null; + }, + load(id: string) { + if (id.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX)) { + return originalSources.get(id) ?? null; + } + return null; + }, transform(code: string, id: string) { if (!id) { // Some modules apparently don't have an id? return null; } + if (id.startsWith(TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX)) { + return null; + } // Convert file:// URLs to regular paths at entry point // Node.js ESM loader hooks provide file:// URLs, but downstream code expects paths @@ -123,25 +170,52 @@ export const unplugin = createUnplugin( const normalizedModulePath = moduleDetails.path.replace(/\\/g, "/"); const moduleVersion = getModuleVersion(moduleDetails.basedir); + let nextCode = code; + let didPatch = false; + // Per-package source patches (see loader/special-case-patches.ts). - // Same anti-pattern fallback the runtime loader uses — mirrored here - // so bundled apps get the patches without relying on hook.mjs. - // Skipped for browser bundles since the wrapper templates use - // `node:module`/`require` to resolve `@mastra/observability`. + // Skipped for browser bundles to preserve the historical behavior of + // the OpenAI APIPromise patch path. if (options.browser !== true) { const patched = applySpecialCasePatch({ packageName: moduleName, modulePath: normalizedModulePath, - source: code, + source: nextCode, format: isModule ? "esm" : "cjs", }); if (patched !== null) { - return { code: patched, map: null }; + nextCode = patched; + didPatch = true; } } + const originalModuleSpecifier = `${TOP_LEVEL_ORIGINAL_IMPORT_PREFIX}${nextOriginalId++}`; + const originalModuleId = `${TOP_LEVEL_ORIGINAL_RESOLVED_PREFIX}${originalModuleSpecifier.slice( + TOP_LEVEL_ORIGINAL_IMPORT_PREFIX.length, + )}`; + const topLevelWrapper = buildTopLevelImportHookSourceWrapper( + topLevelImportHooks, + { + format: isModule ? "esm" : "cjs", + modulePath: normalizedModulePath, + originalModuleSpecifier, + packageName: moduleName, + source: nextCode, + target: topLevelImportHookTarget, + }, + ); + if (topLevelWrapper !== null) { + originalSources.set(originalModuleId, nextCode); + originalDirectories.set(originalModuleId, dirname(filePath)); + nextCode = topLevelWrapper; + didPatch = true; + } + // If no version found if (!moduleVersion) { + if (didPatch) { + return { code: nextCode, map: null }; + } console.warn( `No 'package.json' version found for module ${moduleName} at ${moduleDetails.basedir}. Skipping transformation.`, ); @@ -157,13 +231,13 @@ export const unplugin = createUnplugin( if (!transformer) { // No instrumentations match this file - return null; + return didPatch ? { code: nextCode, map: null } : null; } try { // Transform the code const moduleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform(nextCode, moduleType); const transformedCode = result.code.replace( /const \{tracingChannel: ([A-Za-z_$][\w$]*)\} = ([A-Za-z_$][\w$]*);/g, "const $1 = $2.tracingChannel;", diff --git a/js/src/auto-instrumentations/bundler/webpack-loader.ts b/js/src/auto-instrumentations/bundler/webpack-loader.ts index c218c21a6..bb72bb0e3 100644 --- a/js/src/auto-instrumentations/bundler/webpack-loader.ts +++ b/js/src/auto-instrumentations/bundler/webpack-loader.ts @@ -27,6 +27,15 @@ import { readFileSync } from "fs"; import moduleDetailsFromPath from "module-details-from-path"; import { getDefaultInstrumentationConfigs } from "../configs/all"; import { type LegacyBundlerPluginOptions } from "./plugin"; +import { applySpecialCasePatch } from "../loader/special-case-patches"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + type TopLevelImportHookTarget, +} from "../loader/top-level-export-patches"; +import { readDisabledInstrumentationEnvConfig } from "../../instrumentation/config"; + +const TOP_LEVEL_ORIGINAL_QUERY = "braintrust-top-level-original"; /** * Helper function to get module version from package.json @@ -94,11 +103,15 @@ function codeTransformerLoader( const callback = this.async(); const options: LegacyBundlerPluginOptions = this.getOptions() ?? {}; const resourcePath: string = this.resourcePath; + const resourceQuery: string = this.resourceQuery ?? ""; // Skip virtual modules (e.g. Next.js loaders pass query-string URLs with no real path) if (!resourcePath) { return callback(null, code, inputSourceMap); } + if (resourceQuery.includes(TOP_LEVEL_ORIGINAL_QUERY)) { + return callback(null, code, inputSourceMap); + } // Determine if this is an ES module using multiple methods for accurate detection const ext = extname(resourcePath); @@ -122,12 +135,54 @@ function codeTransformerLoader( const moduleName = moduleDetails.name; const moduleVersion = getModuleVersion(moduleDetails.basedir); - if (!moduleVersion) { - return callback(null, code, inputSourceMap); - } - // Normalize the module path for Windows compatibility (WASM transformer expects forward slashes) const normalizedModulePath = moduleDetails.path.replace(/\\/g, "/"); + const moduleType: ModuleType = isModule ? "esm" : "cjs"; + const target: TopLevelImportHookTarget = + options.browser === true ? "browser" : "node"; + const disabledIntegrationConfig = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target, + }); + + let nextCode = code; + let didPatch = false; + + if (options.browser !== true) { + const patched = applySpecialCasePatch({ + format: moduleType, + modulePath: normalizedModulePath, + packageName: moduleName, + source: nextCode, + }); + if (patched !== null) { + nextCode = patched; + didPatch = true; + } + } + + const topLevelWrapper = buildTopLevelImportHookSourceWrapper( + topLevelImportHooks, + { + format: moduleType, + modulePath: normalizedModulePath, + originalModuleSpecifier: `${resourcePath}?${TOP_LEVEL_ORIGINAL_QUERY}`, + packageName: moduleName, + source: nextCode, + target, + }, + ); + if (topLevelWrapper !== null) { + nextCode = topLevelWrapper; + didPatch = true; + } + + if (!moduleVersion) { + return callback(null, nextCode, inputSourceMap); + } const matcher = getMatcher(options); const transformer = matcher.getTransformer( @@ -137,19 +192,18 @@ function codeTransformerLoader( ); if (!transformer) { - return callback(null, code, inputSourceMap); + return callback(null, nextCode, inputSourceMap); } try { - const moduleType: ModuleType = isModule ? "esm" : "cjs"; - const result = transformer.transform(code, moduleType); + const result = transformer.transform(nextCode, moduleType); callback(null, result.code, result.map ?? undefined); } catch (error) { console.warn( `[code-transformer-loader] Error transforming ${resourcePath}:`, error, ); - callback(null, code, inputSourceMap); + callback(null, didPatch ? nextCode : code, inputSourceMap); } } diff --git a/js/src/auto-instrumentations/configs/all.ts b/js/src/auto-instrumentations/configs/all.ts index b4642974a..f28905c91 100644 --- a/js/src/auto-instrumentations/configs/all.ts +++ b/js/src/auto-instrumentations/configs/all.ts @@ -96,12 +96,9 @@ const defaultInstrumentationConfigGroups: readonly InstrumentationConfigGroup[] configs: flueConfigs, }, // Note: `@mastra/core` is not listed here because its instrumentation - // doesn't go through the AST `code-transformer` matcher — Mastra's - // content-hashed chunks make `filePath`-based matching too brittle. - // Instead it's handled by the source-replacement entry in - // `loader/special-case-patches.ts`, which both the runtime loader - // (`hook.mjs` → `cjs-patch.ts`/`esm-hook.mts`) and the bundler plugin - // (`bundler/plugin.ts`) call. The `mastra` env-var disable still works. + // doesn't go through the AST `code-transformer` matcher. It is handled by + // the internal top-level import hook registry in + // `loader/top-level-export-patches.ts`. ]; export function getDefaultInstrumentationConfigs({ diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index 10626cebd..d8e62ffed 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -23,6 +23,11 @@ import { installMastraExporterFactory } from "./loader/mastra-observability-patc import { getDefaultAutoInstrumentationConfigs } from "./configs/all.js"; import { ModulePatch } from "./loader/cjs-patch.js"; import { patchTracingChannel } from "./patch-tracing-channel.js"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "./loader/top-level-export-patches.js"; +import { installNodeTopLevelExportPatches } from "./loader/top-level-export-patches-node.js"; const state = ((globalThis as any)[ Symbol.for("braintrust.applyAutoInstrumentation") @@ -52,6 +57,19 @@ if (!alreadyApplied) { installMastraExporterFactory(() => new BraintrustObservabilityExporter()); } + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }); + installTopLevelImportHookRunner(topLevelImportHooks); + installNodeTopLevelExportPatches({ + hooks: topLevelImportHooks, + importInTheMiddleHookSpecifier: "./import-in-the-middle/hook.mjs", + importInTheMiddleSpecifier: "./import-in-the-middle", + parentURL: import.meta.url, + requireInTheMiddleSpecifier: "./require-in-the-middle", + }); + // 1. Register ESM loader for ESM modules register("./loader/esm-hook.mjs", { parentURL: import.meta.url, diff --git a/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts b/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts index cda12afdf..0abc7d319 100644 --- a/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts +++ b/js/src/auto-instrumentations/loader/mastra-observability-patch.test.ts @@ -1,117 +1,176 @@ import { describe, expect, it } from "vitest"; import { - classifyMastraTarget, - patchMastraSource, + installMastraExporterFactory, + patchMastraExports, } from "./mastra-observability-patch"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -describe("classifyMastraTarget", () => { - it("identifies @mastra/core main and submodule entries", () => { - expect(classifyMastraTarget("@mastra/core", "dist/index.js")).toBe("core"); - expect(classifyMastraTarget("@mastra/core", "dist/index.cjs")).toBe("core"); - expect(classifyMastraTarget("@mastra/core", "dist/mastra/index.js")).toBe( - "core", - ); - expect(classifyMastraTarget("@mastra/core", "dist/mastra/index.cjs")).toBe( - "core", - ); - }); +const fixturesDir = join( + dirname(fileURLToPath(import.meta.url)), + "../../../tests/auto-instrumentations/fixtures", +); +const mastraCoreBaseDir = join(fixturesDir, "node_modules/@mastra/core"); - it("identifies @mastra/observability entry", () => { - expect(classifyMastraTarget("@mastra/observability", "dist/index.js")).toBe( - "observability", - ); - expect( - classifyMastraTarget("@mastra/observability", "dist/index.cjs"), - ).toBe("observability"); - }); +describe("patchMastraExports — runtime @mastra/observability export", () => { + it("wraps Observability and appends the Braintrust exporter", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); - it("returns null for unrelated paths", () => { - expect( - classifyMastraTarget("@mastra/core", "dist/agent/index.js"), - ).toBeNull(); - expect( - classifyMastraTarget("@mastra/core", "dist/chunk-XYZ.js"), - ).toBeNull(); - expect(classifyMastraTarget("openai", "dist/index.js")).toBeNull(); - }); -}); + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); -describe("patchMastraSource — @mastra/core ESM entry", () => { - it("rewrites a thin re-export into a Proxy-wrapped class", () => { - const original = `export { Mastra } from './chunk-PLCLLPJL.js';\n`; - const patched = patchMastraSource(original, "core", "esm"); - - // The rewritten source must import the original class from the same chunk - expect(patched).toContain( - `import { Mastra as __braintrustOrigMastra } from "./chunk-PLCLLPJL.js"`, - ); - // It must wrap in a Proxy with a `construct` trap - expect(patched).toContain("new Proxy(__braintrustOrigMastra,"); - expect(patched).toContain("construct(target, args, newTarget)"); - // It must re-export `Mastra` so consumers' bindings are unchanged - expect(patched).toContain("export { Mastra }"); - // It must pull Observability via createRequire so it resolves from the - // user's node_modules tree (not our SDK's) - expect(patched).toContain("createRequire"); - expect(patched).toContain(`__braintrustRequire("@mastra/observability")`); + const instance = new namespace.Observability({ + configs: { + default: { + exporters: [{ name: "other" }], + serviceName: "service", + }, + }, + }); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "other" }, { name: "braintrust" }], + serviceName: "service", + }, + }, + }); }); - it("returns the original source when the re-export shape doesn't match", () => { - const original = `// arbitrary code that doesn't re-export Mastra\n`; - const patched = patchMastraSource(original, "core", "esm"); - expect(patched).toBe(original); + it("does not duplicate an existing Braintrust exporter", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + + const instance = new namespace.Observability({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "service", + }, + }, + }); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "service", + }, + }, + }); }); - it("preserves the exact chunk path Mastra references", () => { - const original = `export { Mastra } from '../chunk-DIFFERENT.js';\n`; - const patched = patchMastraSource(original, "core", "esm"); - expect(patched).toContain("../chunk-DIFFERENT.js"); + it("creates a default config when none is provided", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Observability { + public config: unknown; + constructor(config: unknown) { + this.config = config; + } + } + const namespace = { Observability }; + + patchMastraExports(namespace, { moduleName: "@mastra/observability" }); + + const instance = new namespace.Observability(undefined); + + expect(instance.config).toEqual({ + configs: { + default: { + exporters: [{ name: "braintrust" }], + serviceName: "mastra", + }, + }, + }); }); }); -describe("patchMastraSource — @mastra/core CJS entry", () => { - it("rewrites the require + defineProperty shape into a Proxy", () => { - const original = `'use strict'; -var chunkVOP4TUHG_cjs = require('./chunk-VOP4TUHG.cjs'); -Object.defineProperty(exports, "Mastra", { - enumerable: true, - get: function () { return chunkVOP4TUHG_cjs.Mastra; } -}); -`; - const patched = patchMastraSource(original, "core", "cjs"); - - expect(patched).toContain(`require("./chunk-VOP4TUHG.cjs")`); - expect(patched).toContain(`require("@mastra/observability")`); - expect(patched).toContain("__braintrustChunk.Mastra"); - expect(patched).toContain("new Proxy("); - expect(patched).toContain(`Object.defineProperty(exports, "Mastra"`); +describe("patchMastraExports — runtime @mastra/core export", () => { + it("wraps Mastra and injects default Observability when config has none", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Mastra { + public observability: any; + constructor(config: any = {}) { + this.observability = config.observability; + } + } + const namespace = { Mastra }; + + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + + const instance = new namespace.Mastra({}); + + expect(instance.observability).toBeTruthy(); + expect( + instance.observability.config.configs.default.exporters.map( + (exporter: { name: string }) => exporter.name, + ), + ).toEqual(["braintrust"]); }); -}); -describe("patchMastraSource — @mastra/observability entry", () => { - it("appends a Proxy wrap, leaving the original source intact above", () => { - const original = `var Observability = class extends MastraBase {};\nexport { Observability };\n`; - const patched = patchMastraSource(original, "observability", "esm"); - - // Original source still starts the file - expect(patched.startsWith(original)).toBe(true); - // Append wraps the Observability binding via Proxy - expect(patched).toContain("function __braintrustWrapObservability"); - expect(patched).toContain( - `if (typeof Observability === "undefined") return`, - ); - expect(patched).toContain("new Proxy(__OriginalObservability"); - expect(patched).toContain("factory()"); - expect(patched).toContain("__braintrustWrapped"); + it("preserves a user-provided observability config", () => { + delete (globalThis as any).__braintrustMastraExporterFactory; + installMastraExporterFactory(() => ({ name: "braintrust" })); + + class Mastra { + public observability: any; + constructor(config: any = {}) { + this.observability = config.observability; + } + } + const namespace = { Mastra }; + const observability = { user: true }; + + patchMastraExports(namespace, { + baseDir: mastraCoreBaseDir, + moduleName: "@mastra/core", + }); + + const instance = new namespace.Mastra({ observability }); + + expect(instance.observability).toBe(observability); }); - it("doesn't depend on chunk path extraction for observability", () => { - // The Observability entry is one big inline file, not a re-export. - // patchMastraSource should still produce a valid append even when no - // chunk-shaped pattern is present in the source. - const original = `// big inline bundle\nvar Observability = class {};\nexport { Observability };\n`; - const patched = patchMastraSource(original, "observability", "esm"); - expect(patched).not.toBe(original); - expect(patched.length).toBeGreaterThan(original.length); + it("is a no-op when @mastra/observability is unavailable", () => { + class Mastra {} + const namespace = { Mastra }; + + patchMastraExports(namespace, { + baseDir: dirname(fileURLToPath(import.meta.url)), + moduleName: "@mastra/core", + }); + + expect(namespace.Mastra).toBe(Mastra); }); }); diff --git a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts index 578b7a48c..7df6eaa87 100644 --- a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts +++ b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts @@ -1,46 +1,14 @@ /** - * Runtime patches that auto-install the Braintrust observability exporter for - * Mastra users. + * Runtime hook implementation for Mastra top-level exports. * - * Two entry shapes need handling, and they're each different enough that we - * pattern-match them separately rather than trying to share one strategy: - * - * 1. **`@mastra/core` `Mastra` entries** (`dist/index.{js,cjs}` and - * `dist/mastra/index.{js,cjs}`) are thin re-exports from a content-hashed - * chunk that changes filename every release: - * - * // dist/index.js - * export { Mastra } from "./chunk-PLCLLPJL.js"; - * - * The entry filename itself is stable (pinned by the package's `exports` - * map), so we patch it — but a re-export has no local binding to mutate, - * so we have to rewrite the line into an explicit import + Proxy + export. - * That means extracting the chunk path with a regex. The regex pattern is - * stable; only the chunk hash inside the matched string changes. - * - * 2. **`@mastra/observability` `Observability` entry** (`dist/index.{js,cjs}`) - * inlines its class declaration in the entry itself: - * - * var Observability = class extends MastraBase { ... }; - * export { ..., Observability, ... }; - * - * Because the local `Observability` is a `var` binding, we can *append* a - * wrap (reassign `Observability` to a Proxy) and ESM's live-binding - * semantics propagate it to importers. No source rewrite needed, no chunk - * path involved. - * - * In both cases the generated wrapper is a `Proxy` with a `construct` trap. - * On the Mastra side the trap injects an `Observability` instance into the - * config when the user didn't pass one (via a factory registered on - * `globalThis` by `hook.mjs`). On the Observability side the trap walks the - * `configs` map and appends our exporter to any instance config that doesn't - * already have one with `name === "braintrust"`. - * - * The patch is a no-op if the regex doesn't match (e.g., Mastra restructures - * its build) or if our globalThis factory isn't present (e.g., user disabled - * via `BRAINTRUST_DISABLE_INSTRUMENTATION=mastra`). + * The IITM/RITM adapters pass real module export objects here. Bundler/source + * adapters generate wrapper modules that build the same mutable namespace + * facade, call the shared hook runner, and re-export the updated bindings. */ +import { createRequire } from "node:module"; +import { join } from "node:path"; + /** * Name of the `globalThis` property the generated patches read to look up the * Braintrust exporter factory at runtime. Set by `hook.mjs` (loader path) and @@ -65,224 +33,247 @@ export function installMastraExporterFactory(factory: () => unknown): void { const MASTRA_CORE_PACKAGE = "@mastra/core"; const MASTRA_OBSERVABILITY_PACKAGE = "@mastra/observability"; +const MASTRA_CORE_MASTRA_SPECIFIER = "@mastra/core/mastra"; +const MASTRA_RUNTIME_WRAPPED = Symbol.for( + "braintrust.mastra.runtime-export-wrapped", +); -// Entrypoints pinned by each package's `exports` map. Both Mastra entries -// (`dist/index.js` and `dist/mastra/index.js`) re-export `Mastra` from a -// chunk, so they both need the rewrite treatment. -const MASTRA_CORE_ENTRY_PATHS = new Set([ - "dist/index.js", - "dist/index.cjs", - "dist/mastra/index.js", - "dist/mastra/index.cjs", -]); -const MASTRA_OBSERVABILITY_ENTRY_PATHS = new Set([ - "dist/index.js", - "dist/index.cjs", -]); +export interface MastraRuntimePatchContext { + moduleName: string; + baseDir?: string; + resolutionBase?: string; +} -export type MastraTargetKind = "core" | "observability"; -export type MastraModuleFormat = "esm" | "cjs"; +type RuntimeConstructor = new (...args: unknown[]) => unknown; -export function classifyMastraTarget( - packageName: string, - modulePath: string, -): MastraTargetKind | null { +export function patchMastraExports( + exportsValue: T, + context: MastraRuntimePatchContext, +): T { if ( - packageName === MASTRA_CORE_PACKAGE && - MASTRA_CORE_ENTRY_PATHS.has(modulePath) + context.moduleName === MASTRA_CORE_PACKAGE || + context.moduleName === MASTRA_CORE_MASTRA_SPECIFIER ) { - return "core"; + return patchMastraCoreExports(exportsValue, context); + } else if (context.moduleName === MASTRA_OBSERVABILITY_PACKAGE) { + return patchMastraObservabilityExports(exportsValue); } - if ( - packageName === MASTRA_OBSERVABILITY_PACKAGE && - MASTRA_OBSERVABILITY_ENTRY_PATHS.has(modulePath) - ) { - return "observability"; + return exportsValue; +} + +function patchMastraCoreExports( + exportsValue: unknown, + context: MastraRuntimePatchContext, +): unknown { + if (!exportsValue || typeof exportsValue !== "object") return exportsValue; + + const namespace = exportsValue as Record; + const Mastra = asRuntimeConstructor(namespace.Mastra); + if (!Mastra || isRuntimeWrapped(Mastra)) return exportsValue; + + const Observability = loadObservabilityClass(context); + if (!Observability) return exportsValue; + + const wrapped = new Proxy(Mastra, { + construct(target, args, newTarget) { + const firstArg = args[0]; + if ( + (!firstArg || + typeof firstArg !== "object" || + !("observability" in firstArg) || + !(firstArg as { observability?: unknown }).observability) && + Observability + ) { + try { + const observability = new Observability({ + configs: { default: { serviceName: "mastra" } }, + }); + const nextConfig = + firstArg && typeof firstArg === "object" + ? { ...(firstArg as Record), observability } + : { observability }; + return Reflect.construct( + target, + [nextConfig, ...args.slice(1)], + newTarget, + ); + } catch { + // Mastra will keep its own fallback behavior if constructing + // Observability fails. + } + } + return Reflect.construct(target, args, newTarget); + }, + }); + markRuntimeWrapped(wrapped); + + if (setNamespaceExport(namespace, "Mastra", wrapped)) { + return exportsValue; } - return null; + return cloneNamespaceWithExport(namespace, "Mastra", wrapped); } -// Extract the chunk path from a Mastra entry source. -// ESM shape: `export { Mastra } from "../chunk-XYZ.js";` -// CJS shape: `var chunkXYZ_cjs = require("../chunk-XYZ.cjs"); ...` -// Returns null when the shape isn't what we expect — caller falls through and -// emits the original source unchanged so user code keeps working. -function extractChunkPath(source: string): string | null { - const esmMatch = source.match( - /export\s*\{\s*Mastra(?:\s+as\s+\w+)?\s*\}\s*from\s*['"]([^'"]+)['"]/, - ); - if (esmMatch) return esmMatch[1]; +function patchMastraObservabilityExports(exportsValue: unknown): unknown { + if (!exportsValue || typeof exportsValue !== "object") return exportsValue; - const cjsMatch = source.match( - /require\s*\(\s*['"]([^'"]+chunk-[^'"]+)['"]\s*\)/, - ); - if (cjsMatch) return cjsMatch[1]; + const namespace = exportsValue as Record; + const Observability = asRuntimeConstructor(namespace.Observability); + if (!Observability || isRuntimeWrapped(Observability)) { + return exportsValue; + } + + const wrapped = new Proxy(Observability, { + construct(target, args, newTarget) { + const nextArgs = args.slice(); + nextArgs[0] = ensureBraintrustExporter(nextArgs[0]); + return Reflect.construct(target, nextArgs, newTarget); + }, + }); + markRuntimeWrapped(wrapped); - return null; + if (setNamespaceExport(namespace, "Observability", wrapped)) { + return exportsValue; + } + return cloneNamespaceWithExport(namespace, "Observability", wrapped); +} + +function loadObservabilityClass( + context: MastraRuntimePatchContext, +): RuntimeConstructor | undefined { + try { + const requireFromMastra = createRequire( + context.resolutionBase ?? + (context.baseDir + ? join(context.baseDir, "package.json") + : join(process.cwd(), "package.json")), + ); + const observability = requireFromMastra(MASTRA_OBSERVABILITY_PACKAGE); + patchMastraObservabilityExports(observability); + return asRuntimeConstructor(observability?.Observability); + } catch { + return undefined; + } } -// All wrapper templates below are emitted as runtime JS in the target module's -// scope. They never reference Braintrust SDK code directly — instead they look -// up a factory function on `globalThis` (registered by `hook.mjs`). That keeps -// the patch independent of how the user's module graph resolves `braintrust`, -// and lets us cleanly no-op when the user disables our integration via env. +function ensureBraintrustExporter(rawConfig: unknown): unknown { + try { + const factory = (globalThis as Record)[ + MASTRA_EXPORTER_FACTORY_GLOBAL + ]; + if (typeof factory !== "function") return rawConfig; -const EXPORTER_FACTORY_KEY = JSON.stringify(MASTRA_EXPORTER_FACTORY_GLOBAL); + const config = + rawConfig && typeof rawConfig === "object" + ? (rawConfig as Record) + : {}; + const configsIn = + config.configs && typeof config.configs === "object" + ? (config.configs as Record) + : undefined; + const configsOut: Record = {}; + let hadEntries = false; -// Construct-trap body shared between ESM and CJS wrappers. The wrapper loads -// `@mastra/observability` at module-evaluation time from the Mastra entry's -// own location (so the resolver finds the user's `node_modules`, not our -// SDK's). When the user constructs `new Mastra(...)` without an observability -// config, the trap injects a default `Observability` — which, because it -// loaded through our patched ESM hook / CJS patch, already auto-installs the -// Braintrust exporter via its own constructor wrap. -const MASTRA_PROXY_HANDLER_BODY = ` -{ - construct(target, args, newTarget) { - var firstArg = args[0]; - if ( - (!firstArg || typeof firstArg !== "object" || !firstArg.observability) && - __braintrustObservabilityClass - ) { - try { - // serviceName is required by Mastra's Observability validator; pass - // something sensible by default. Users who want a different name - // should construct Observability themselves. - var observability = new __braintrustObservabilityClass({ - configs: { default: { serviceName: "mastra" } }, - }); - args = args.slice(); - args[0] = Object.assign({}, firstArg, { observability: observability }); - } catch (e) { - // Fall through. Mastra will use its own NoOp; user code still works, - // just without auto-instrumentation. + if (configsIn) { + for (const [name, rawInstanceConfig] of Object.entries(configsIn)) { + hadEntries = true; + const instanceConfig = + rawInstanceConfig && typeof rawInstanceConfig === "object" + ? (rawInstanceConfig as Record) + : {}; + const existing = Array.isArray(instanceConfig.exporters) + ? instanceConfig.exporters + : []; + const hasOurs = existing.some( + (exporter) => + exporter && + typeof exporter === "object" && + (exporter as { name?: unknown }).name === "braintrust", + ); + configsOut[name] = { + ...instanceConfig, + exporters: hasOurs ? existing : [...existing, factory()], + }; } } - return Reflect.construct(target, args, newTarget); - }, -}`; -function buildMastraEsmWrapper(chunkPath: string): string { - const chunk = JSON.stringify(chunkPath); - return `import { Mastra as __braintrustOrigMastra } from ${chunk}; -import { createRequire as __braintrustCreateRequire } from "node:module"; + if (!hadEntries) { + configsOut.default = { + serviceName: "mastra", + exporters: [factory()], + }; + } -let __braintrustObservabilityClass = null; -try { - // Resolve @mastra/observability relative to this module (the Mastra entry), - // so it's looked up from the user's node_modules tree. - const __braintrustRequire = __braintrustCreateRequire(import.meta.url); - __braintrustObservabilityClass = - __braintrustRequire("@mastra/observability").Observability; -} catch (e) { - // @mastra/observability isn't installed; the construct trap will skip the - // auto-construct branch and Mastra falls back to its own NoOp. -} -const Mastra = new Proxy(__braintrustOrigMastra, ${MASTRA_PROXY_HANDLER_BODY}); -export { Mastra }; -`; + return { ...config, configs: configsOut }; + } catch { + return rawConfig; + } } -function buildMastraCjsWrapper(chunkPath: string): string { - const chunk = JSON.stringify(chunkPath); - return `'use strict'; -const __braintrustChunk = require(${chunk}); - -let __braintrustObservabilityClass = null; -try { - __braintrustObservabilityClass = require("@mastra/observability").Observability; -} catch (e) { - // @mastra/observability isn't installed; same fallback as the ESM wrapper. +function asRuntimeConstructor(value: unknown): RuntimeConstructor | undefined { + return typeof value === "function" + ? (value as RuntimeConstructor) + : undefined; } -const __braintrustWrappedMastra = new Proxy( - __braintrustChunk.Mastra, - ${MASTRA_PROXY_HANDLER_BODY}, -); -Object.defineProperty(exports, "Mastra", { - enumerable: true, - configurable: true, - get: function () { return __braintrustWrappedMastra; } -}); -`; +function isRuntimeWrapped(value: RuntimeConstructor): boolean { + return ( + (value as unknown as Record)[MASTRA_RUNTIME_WRAPPED] === + true + ); } -// Appended to the end of `@mastra/observability/dist/index.{js,cjs}`. The -// entry declares `var Observability = class extends MastraBase { ... }`, -// which means we can reassign the binding from inside the same module after -// the class is created and the export line has run. ESM live-binding -// semantics make external importers see the new value; CJS lookups go -// through `exports.Observability`, which we redefine to mirror the wrap. -const OBSERVABILITY_APPEND_BODY = ` -;(function __braintrustWrapObservability() { - // Top-level so we can both read and reassign the var binding the original - // entry declared. - if (typeof Observability === "undefined") return; - if (Observability.__braintrustWrapped) return; - function __braintrustEnsureExporter(rawConfig) { - try { - var factory = globalThis[${EXPORTER_FACTORY_KEY}]; - if (typeof factory !== "function") return rawConfig; - var config = rawConfig && typeof rawConfig === "object" ? rawConfig : {}; - var configsIn = config.configs && typeof config.configs === "object" ? config.configs : null; - var configsOut = {}; - var hadEntries = false; - if (configsIn) { - for (var name in configsIn) { - if (!Object.prototype.hasOwnProperty.call(configsIn, name)) continue; - hadEntries = true; - var inst = configsIn[name] || {}; - var existing = Array.isArray(inst.exporters) ? inst.exporters : []; - var hasOurs = existing.some(function (e) { return e && e.name === "braintrust"; }); - configsOut[name] = Object.assign({}, inst, { - exporters: hasOurs ? existing : existing.concat([factory()]), - }); - } - } - if (!hadEntries) { - configsOut.default = { - serviceName: "mastra", - exporters: [factory()], - }; - } - return Object.assign({}, config, { configs: configsOut }); - } catch (e) { - return rawConfig; - } +function markRuntimeWrapped(value: RuntimeConstructor): void { + try { + Object.defineProperty(value, MASTRA_RUNTIME_WRAPPED, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + // Best effort only. Idempotence still holds through the export assignment. } - var __OriginalObservability = Observability; - Observability = new Proxy(__OriginalObservability, { - construct: function (target, args, newTarget) { - var nextArgs = args.slice(); - nextArgs[0] = __braintrustEnsureExporter(nextArgs[0]); - return Reflect.construct(target, nextArgs, newTarget); - }, - }); - Observability.__braintrustWrapped = true; - if (typeof exports !== "undefined" && exports && typeof exports === "object") { - try { - Object.defineProperty(exports, "Observability", { - enumerable: true, - configurable: true, - get: function () { return Observability; }, - }); - } catch (e) {} +} + +function setNamespaceExport( + namespace: Record, + key: string, + value: RuntimeConstructor, +): boolean { + try { + namespace[key] = value; + if (namespace[key] === value) return true; + } catch { + // Try defineProperty below. } -})(); -`; -export function patchMastraSource( - source: string, - target: MastraTargetKind, - format: MastraModuleFormat, -): string { - if (target === "core") { - const chunkPath = extractChunkPath(source); - if (!chunkPath) return source; - return format === "esm" - ? buildMastraEsmWrapper(chunkPath) - : buildMastraCjsWrapper(chunkPath); + try { + Object.defineProperty(namespace, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + return namespace[key] === value; + } catch { + return false; } - // Observability: append-wrap, leaving original source intact above. - return source + OBSERVABILITY_APPEND_BODY; +} + +function cloneNamespaceWithExport( + namespace: Record, + key: string, + value: RuntimeConstructor, +): Record { + return Object.defineProperties( + Object.create(Object.getPrototypeOf(namespace)), + { + ...Object.getOwnPropertyDescriptors(namespace), + [key]: { + configurable: true, + enumerable: true, + value, + writable: true, + }, + }, + ); } diff --git a/js/src/auto-instrumentations/loader/special-case-patches.ts b/js/src/auto-instrumentations/loader/special-case-patches.ts index 502b0af51..8f6e37728 100644 --- a/js/src/auto-instrumentations/loader/special-case-patches.ts +++ b/js/src/auto-instrumentations/loader/special-case-patches.ts @@ -21,23 +21,11 @@ * .parse()` doesn't double-read the response body. Removable once OpenAI * stops sharing the same `APIPromise` between `create()` and * `_thenUnwrap()`. - * - `@mastra/core` and `@mastra/observability` entries: Mastra ships - * code-split bundles with content-hashed chunk filenames, so we patch - * the stable submodule entries to install the - * `BraintrustObservabilityExporter` automatically. Removable when Mastra - * adopts a NPM-installable Braintrust exporter package directly, or when - * `import-in-the-middle` is reliable enough across Node versions to use - * for the same job. */ -import { - classifyMastraTarget, - patchMastraSource, - type MastraModuleFormat, -} from "./mastra-observability-patch.js"; import { OPENAI_API_PROMISE_PATCH } from "./openai-api-promise-patch.js"; -export type SpecialCaseFormat = MastraModuleFormat; +export type SpecialCaseFormat = "esm" | "cjs"; export interface SpecialCaseInput { packageName: string; @@ -62,16 +50,6 @@ export function applySpecialCasePatch(input: SpecialCaseInput): string | null { return input.source + OPENAI_API_PROMISE_PATCH; } - // Mastra: rewrite the stable submodule entries (@mastra/core) or append a - // Proxy wrap to the inline class binding (@mastra/observability). - const mastraTarget = classifyMastraTarget( - input.packageName, - input.modulePath, - ); - if (mastraTarget) { - return patchMastraSource(input.source, mastraTarget, input.format); - } - return null; } @@ -87,8 +65,5 @@ export function isSpecialCaseTarget( if (packageName === "openai" && modulePath.includes("api-promise")) { return true; } - if (classifyMastraTarget(packageName, modulePath) !== null) { - return true; - } return false; } diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts new file mode 100644 index 000000000..36771ae06 --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts @@ -0,0 +1,75 @@ +import { createRequire, register } from "node:module"; +import { debugLogger } from "../../debug-logger"; +import { + getTopLevelImportHookSpecifiers, + runTopLevelImportHooks, + type TopLevelImportHook, +} from "./top-level-export-patches.js"; + +export interface InstallNodeTopLevelExportPatchesOptions { + hooks: readonly TopLevelImportHook[]; + parentURL: string; + importInTheMiddleHookSpecifier: string; + importInTheMiddleSpecifier: string; + requireInTheMiddleSpecifier: string; +} + +export function installNodeTopLevelExportPatches({ + hooks, + parentURL, + importInTheMiddleHookSpecifier, + importInTheMiddleSpecifier, + requireInTheMiddleSpecifier, +}: InstallNodeTopLevelExportPatchesOptions): void { + const specifiers = getTopLevelImportHookSpecifiers(hooks); + if (specifiers.length === 0) return; + + const requireFromParent = createRequire(parentURL); + + try { + register(importInTheMiddleHookSpecifier, { + parentURL, + data: { include: specifiers }, + } as any); + + const iitm = requireFromParent(importInTheMiddleSpecifier) as { + Hook: new ( + modules: string[], + hookFn: ( + exportsValue: unknown, + name: string, + baseDir?: string, + ) => unknown, + ) => unknown; + }; + new iitm.Hook(specifiers, (exportsValue, name, baseDir) => + runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + }), + ); + } catch (err) { + debugLogger.warn("Failed to install ESM top-level import hooks", err); + } + + try { + const ritm = requireFromParent(requireInTheMiddleSpecifier) as { + Hook: new ( + modules: string[], + hookFn: ( + exportsValue: unknown, + name: string, + baseDir?: string, + ) => unknown, + ) => unknown; + }; + new ritm.Hook(specifiers, (exportsValue, name, baseDir) => + runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + }), + ); + } catch (err) { + debugLogger.warn("Failed to install CJS top-level import hooks", err); + } +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts b/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts new file mode 100644 index 000000000..f9e8db3e2 --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + buildTopLevelImportHookSourceWrapper, + getDefaultTopLevelImportHooks, + getTopLevelImportHookSpecifiers, + runTopLevelImportHooks, + type TopLevelImportHook, +} from "./top-level-export-patches"; + +describe("top-level import hook registry", () => { + it("returns the Mastra runtime specifiers for node targets", () => { + const hooks = getDefaultTopLevelImportHooks({ target: "node" }); + + expect(getTopLevelImportHookSpecifiers(hooks)).toEqual([ + "@mastra/core", + "@mastra/core/mastra", + "@mastra/observability", + ]); + }); + + it("filters hooks by disabled integration config", () => { + const hooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: { mastra: false }, + target: "node", + }); + + expect(hooks).toEqual([]); + }); + + it("does not include Mastra for browser targets", () => { + const hooks = getDefaultTopLevelImportHooks({ target: "browser" }); + + expect(hooks).toEqual([]); + }); + + it("runs hook callbacks against a mutable namespace", () => { + const hooks: TopLevelImportHook[] = [ + { + hook(exportsValue) { + exportsValue.value = "patched"; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + const namespace = { value: "original" }; + + const result = runTopLevelImportHooks(hooks, namespace, { + moduleName: "pkg", + }); + + expect(result).toBe(namespace); + expect(namespace.value).toBe("patched"); + }); + + it("uses a returned replacement namespace when a hook provides one", () => { + const replacement = { value: "replacement" }; + const hooks: TopLevelImportHook[] = [ + { + hook() { + return replacement; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + + const result = runTopLevelImportHooks( + hooks, + { value: "original" }, + { moduleName: "pkg" }, + ); + + expect(result).toBe(replacement); + }); + + it("passes runtime resolution context through hook callbacks", () => { + let seen: + | { baseDir: string | undefined; resolutionBase: string | undefined } + | undefined; + const hooks: TopLevelImportHook[] = [ + { + hook(_exportsValue, _name, baseDir, resolutionBase) { + seen = { baseDir, resolutionBase }; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }, + ]; + + runTopLevelImportHooks( + hooks, + { value: "original" }, + { + baseDir: "/tmp/pkg", + moduleName: "pkg", + resolutionBase: "file:///tmp/app/bundle.mjs", + }, + ); + + expect(seen).toEqual({ + baseDir: "/tmp/pkg", + resolutionBase: "file:///tmp/app/bundle.mjs", + }); + }); + + it("generates an ESM wrapper that calls the shared hook runner", () => { + const wrapper = buildTopLevelImportHookSourceWrapper([fakeHook("node")], { + format: "esm", + modulePath: "index.js", + originalModuleSpecifier: "braintrust-top-level-original:0", + packageName: "pkg", + source: `export { value } from "./value.js";`, + target: "node", + }); + + expect(wrapper).toContain( + `import * as __braintrustOriginal from "braintrust-top-level-original:0"`, + ); + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(wrapper).toContain( + `export * from "braintrust-top-level-original:0"`, + ); + expect(wrapper).toContain("import.meta.url"); + expect(wrapper).toContain(" as value"); + }); + + it("generates a CJS wrapper that calls the shared hook runner", () => { + const wrapper = buildTopLevelImportHookSourceWrapper([fakeHook("node")], { + format: "cjs", + modulePath: "index.cjs", + originalModuleSpecifier: "/tmp/pkg/index.cjs?braintrust-original", + packageName: "pkg", + source: `exports.value = "original";`, + target: "node", + }); + + expect(wrapper).toContain( + `const __braintrustOriginal = require("/tmp/pkg/index.cjs?braintrust-original")`, + ); + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(wrapper).toContain("typeof __filename"); + expect(wrapper).toContain(`Object.defineProperty(exports, "value"`); + }); + + it("supports browser-safe hook descriptors while Mastra remains node-only", () => { + const wrapper = buildTopLevelImportHookSourceWrapper( + [fakeHook("browser")], + { + format: "esm", + modulePath: "index.js", + originalModuleSpecifier: "braintrust-top-level-original:1", + packageName: "pkg", + source: `export const value = "original";`, + target: "browser", + }, + ); + + expect(wrapper).toContain("__braintrustTopLevelImportHookRunner"); + expect(getDefaultTopLevelImportHooks({ target: "browser" })).toEqual([]); + }); +}); + +function fakeHook(target: "node" | "browser"): TopLevelImportHook { + return { + hook(exportsValue) { + exportsValue.value = "patched"; + }, + integrations: ["mastra"], + sourceTargets: [ + { + exportNames: ["value"], + modulePaths: + target === "node" ? ["index.js", "index.cjs"] : ["index.js"], + packageName: "pkg", + specifier: "pkg", + }, + ], + specifiers: ["pkg"], + targets: [target], + }; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches.ts b/js/src/auto-instrumentations/loader/top-level-export-patches.ts new file mode 100644 index 000000000..76a21be69 --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches.ts @@ -0,0 +1,456 @@ +import type { InstrumentationIntegrationsConfig } from "../../instrumentation/config"; +import { isInstrumentationIntegrationDisabled } from "../../instrumentation/config"; +import { patchMastraExports } from "./mastra-observability-patch.js"; + +export type TopLevelImportHookTarget = "node" | "browser"; +export type TopLevelImportHookFormat = "esm" | "cjs"; +export type MutableExportNamespace = Record; + +export interface TopLevelImportHookSourceTarget { + packageName: string; + modulePaths: readonly string[]; + specifier: string; + exportNames: readonly string[]; +} + +export interface TopLevelImportHook { + integrations: readonly (keyof InstrumentationIntegrationsConfig)[]; + specifiers: readonly string[]; + targets: readonly TopLevelImportHookTarget[]; + sourceTargets?: readonly TopLevelImportHookSourceTarget[]; + hook( + exports: MutableExportNamespace, + name: string, + baseDir?: string, + resolutionBase?: string, + ): unknown | void; +} + +export interface TopLevelImportHookContext { + moduleName: string; + baseDir?: string; + resolutionBase?: string; +} + +export interface TopLevelImportHookSourceWrapperInput { + baseDir?: string; + format: TopLevelImportHookFormat; + modulePath: string; + originalModuleSpecifier: string; + packageName: string; + source: string; + target: TopLevelImportHookTarget; +} + +const TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL = + "__braintrustTopLevelImportHookRunner"; + +export function getDefaultTopLevelImportHooks({ + disabledIntegrationConfig, + target, +}: { + disabledIntegrationConfig?: InstrumentationIntegrationsConfig; + target: TopLevelImportHookTarget; +}): TopLevelImportHook[] { + return defaultTopLevelImportHooks.filter( + (hook) => + hook.targets.includes(target) && + !isInstrumentationIntegrationDisabled( + disabledIntegrationConfig, + ...hook.integrations, + ), + ); +} + +export function getTopLevelImportHookSpecifiers( + hooks: readonly TopLevelImportHook[], +): string[] { + return [...new Set(hooks.flatMap((hook) => hook.specifiers))]; +} + +export function runTopLevelImportHooks( + hooks: readonly TopLevelImportHook[], + exportsValue: unknown, + context: TopLevelImportHookContext, +): unknown { + let nextExports = exportsValue; + for (const hook of hooks) { + if (!matchesRuntimeHook(hook, context.moduleName)) { + continue; + } + + try { + const patched = hook.hook( + asMutableNamespace(nextExports), + context.moduleName, + context.baseDir, + context.resolutionBase, + ); + if (patched !== undefined) { + nextExports = patched; + } + } catch { + // Hook failures must never escape into user module evaluation. + } + } + return nextExports; +} + +export function installTopLevelImportHookRunner( + hooks: readonly TopLevelImportHook[], +): void { + Object.defineProperty(globalThis, TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL, { + configurable: true, + enumerable: false, + value( + exportsValue: unknown, + name: string, + baseDir?: string, + resolutionBase?: string, + ) { + return runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + resolutionBase, + }); + }, + writable: true, + }); +} + +export function buildTopLevelImportHookSourceWrapper( + hooks: readonly TopLevelImportHook[], + input: TopLevelImportHookSourceWrapperInput, +): string | null { + const sourceTargets = hooks + .filter((hook) => hook.targets.includes(input.target)) + .flatMap((hook) => hook.sourceTargets ?? []) + .filter( + (sourceTarget) => + sourceTarget.packageName === input.packageName && + sourceTarget.modulePaths.includes(input.modulePath), + ); + + if (sourceTargets.length === 0) { + return null; + } + + const specifier = sourceTargets[0].specifier; + const exportNames = [ + ...new Set([ + ...sourceTargets.flatMap((target) => target.exportNames), + ...collectStaticExportNames(input.source, input.format), + ]), + ].sort((a, b) => Number(a === "default") - Number(b === "default")); + + if (exportNames.length === 0) { + return null; + } + + return input.format === "esm" + ? buildEsmSourceWrapper({ + baseDir: input.baseDir, + exportNames, + moduleName: specifier, + originalModuleSpecifier: input.originalModuleSpecifier, + }) + : buildCjsSourceWrapper({ + baseDir: input.baseDir, + exportNames, + moduleName: specifier, + originalModuleSpecifier: input.originalModuleSpecifier, + }); +} + +export { + getDefaultTopLevelImportHooks as getDefaultTopLevelExportPatches, + getTopLevelImportHookSpecifiers as getTopLevelExportPatchSpecifiers, + runTopLevelImportHooks as applyTopLevelExportRuntimePatches, +}; + +export type { + TopLevelImportHook as TopLevelExportPatch, + TopLevelImportHookContext as TopLevelExportPatchContext, + TopLevelImportHookFormat as TopLevelExportPatchFormat, + TopLevelImportHookTarget as TopLevelExportPatchTarget, +}; + +function matchesRuntimeHook( + hook: TopLevelImportHook, + moduleName: string, +): boolean { + return hook.specifiers.includes(moduleName); +} + +function asMutableNamespace(exportsValue: unknown): MutableExportNamespace { + if (exportsValue && typeof exportsValue === "object") { + return exportsValue as MutableExportNamespace; + } + + return { default: exportsValue }; +} + +function collectStaticExportNames( + source: string, + format: TopLevelImportHookFormat, +): string[] { + if (format === "cjs") { + const names = new Set(); + for (const match of source.matchAll( + /\bexports\.([A-Za-z_$][\w$]*)\s*=|\bmodule\.exports\.([A-Za-z_$][\w$]*)\s*=|Object\.defineProperty\s*\(\s*exports\s*,\s*["']([^"']+)["']/g, + )) { + names.add(match[1] ?? match[2] ?? match[3]); + } + return [...names]; + } + + const names = new Set(); + + for (const match of source.matchAll( + /\bexport\s+(?:async\s+)?(?:class|function|const|let|var)\s+([A-Za-z_$][\w$]*)/g, + )) { + names.add(match[1]); + } + + if (/\bexport\s+default\b/.test(source)) { + names.add("default"); + } + + for (const match of source.matchAll( + /\bexport\s*\{([^}]+)\}(?:\s*from\s*["'][^"']+["'])?/g, + )) { + for (const part of match[1].split(",")) { + const trimmed = part.trim(); + if (!trimmed || trimmed.startsWith("type ")) continue; + const aliasMatch = trimmed.match(/\bas\s+([A-Za-z_$][\w$]*|default)$/); + const directMatch = trimmed.match(/^([A-Za-z_$][\w$]*|default)$/); + const name = aliasMatch?.[1] ?? directMatch?.[1]; + if (name) names.add(name); + } + } + + return [...names]; +} + +function buildEsmSourceWrapper({ + baseDir, + exportNames, + moduleName, + originalModuleSpecifier, +}: { + baseDir?: string; + exportNames: readonly string[]; + moduleName: string; + originalModuleSpecifier: string; +}): string { + const locals = exportNames.map((name, index) => ({ + exportName: name, + localName: + name === "default" + ? "__braintrustDefaultExport" + : toSafeLocalName(name, index), + })); + + return `import * as __braintrustOriginal from ${JSON.stringify(originalModuleSpecifier)}; + +${locals + .map( + ({ exportName, localName }) => + `let ${localName} = __braintrustOriginal[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + +const __braintrustExports = Object.create(null); +${locals.map(({ exportName, localName }) => buildMutableExportDescriptor(exportName, localName)).join("\n")} + +try { + const __braintrustHookRunner = globalThis[${JSON.stringify(TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL)}]; + if (typeof __braintrustHookRunner === "function") { + const __braintrustPatched = __braintrustHookRunner( + __braintrustExports, + ${JSON.stringify(moduleName)}, + ${JSON.stringify(baseDir)}, + import.meta.url, + ); + if (__braintrustPatched && __braintrustPatched !== __braintrustExports) { +${locals + .map( + ({ exportName, localName }) => + ` if (Object.prototype.hasOwnProperty.call(__braintrustPatched, ${JSON.stringify(exportName)})) ${localName} = __braintrustPatched[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + } + } +} catch (e) { + // Top-level import hooks are best-effort in bundled output. +} + +export * from ${JSON.stringify(originalModuleSpecifier)}; +${buildEsmExports(locals)} +`; +} + +function buildCjsSourceWrapper({ + baseDir, + exportNames, + moduleName, + originalModuleSpecifier, +}: { + baseDir?: string; + exportNames: readonly string[]; + moduleName: string; + originalModuleSpecifier: string; +}): string { + const locals = exportNames.map((name, index) => ({ + exportName: name, + localName: + name === "default" + ? "__braintrustDefaultExport" + : toSafeLocalName(name, index), + })); + const cjsRequire = "require"; + + return `"use strict"; +const __braintrustOriginal = ${cjsRequire}(${JSON.stringify(originalModuleSpecifier)}); +const __braintrustPatchedExportNames = new Set(${JSON.stringify(exportNames)}); +try { + const __braintrustOriginalDescriptors = Object.getOwnPropertyDescriptors(__braintrustOriginal); +${locals.map(({ exportName }) => ` delete __braintrustOriginalDescriptors[${JSON.stringify(exportName)}];`).join("\n")} + Object.defineProperties(exports, __braintrustOriginalDescriptors); +} catch (e) { + for (const __braintrustKey in __braintrustOriginal) { + if (!__braintrustPatchedExportNames.has(__braintrustKey)) { + exports[__braintrustKey] = __braintrustOriginal[__braintrustKey]; + } + } +} + +${locals + .map( + ({ exportName, localName }) => + `let ${localName} = __braintrustOriginal[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + +const __braintrustExports = Object.create(null); +${locals.map(({ exportName, localName }) => buildMutableExportDescriptor(exportName, localName)).join("\n")} + +try { + const __braintrustHookRunner = globalThis[${JSON.stringify(TOP_LEVEL_IMPORT_HOOK_RUNNER_GLOBAL)}]; + if (typeof __braintrustHookRunner === "function") { + const __braintrustPatched = __braintrustHookRunner( + __braintrustExports, + ${JSON.stringify(moduleName)}, + ${JSON.stringify(baseDir)}, + typeof __filename === "string" ? __filename : undefined, + ); + if (__braintrustPatched && __braintrustPatched !== __braintrustExports) { +${locals + .map( + ({ exportName, localName }) => + ` if (Object.prototype.hasOwnProperty.call(__braintrustPatched, ${JSON.stringify(exportName)})) ${localName} = __braintrustPatched[${JSON.stringify(exportName)}];`, + ) + .join("\n")} + } + } +} catch (e) { + // Top-level import hooks are best-effort in bundled output. +} + +${locals.map(({ exportName, localName }) => buildCjsExportDescriptor(exportName, localName)).join("\n")} +`; +} + +function buildMutableExportDescriptor( + exportName: string, + localName: string, +): string { + return `Object.defineProperty(__braintrustExports, ${JSON.stringify(exportName)}, { + configurable: true, + enumerable: true, + get() { return ${localName}; }, + set(value) { ${localName} = value; return true; }, +});`; +} + +function buildEsmExports( + locals: readonly { exportName: string; localName: string }[], +): string { + const named = locals.filter(({ exportName }) => exportName !== "default"); + const defaultExport = locals.find( + ({ exportName }) => exportName === "default", + ); + const lines = named.length + ? [ + `export { ${named.map(({ localName, exportName }) => (localName === exportName ? exportName : `${localName} as ${exportName}`)).join(", ")} };`, + ] + : []; + if (defaultExport) { + lines.push(`export { ${defaultExport.localName} as default };`); + } + return lines.join("\n"); +} + +function buildCjsExportDescriptor( + exportName: string, + localName: string, +): string { + return `Object.defineProperty(exports, ${JSON.stringify(exportName)}, { + configurable: true, + enumerable: true, + get() { return ${localName}; }, +});`; +} + +const MASTRA_CORE_ENTRY_PATHS = [ + "dist/index.js", + "dist/index.cjs", + "dist/mastra/index.js", + "dist/mastra/index.cjs", +]; +const MASTRA_OBSERVABILITY_ENTRY_PATHS = ["dist/index.js", "dist/index.cjs"]; + +const mastraTopLevelImportHook: TopLevelImportHook = { + hook(exportsValue, name, baseDir, resolutionBase) { + return patchMastraExports(exportsValue, { + baseDir, + moduleName: name, + resolutionBase, + }); + }, + integrations: ["mastra"], + sourceTargets: [ + { + exportNames: ["Mastra"], + modulePaths: MASTRA_CORE_ENTRY_PATHS.filter((path) => + path.startsWith("dist/index."), + ), + packageName: "@mastra/core", + specifier: "@mastra/core", + }, + { + exportNames: ["Mastra"], + modulePaths: MASTRA_CORE_ENTRY_PATHS.filter((path) => + path.startsWith("dist/mastra/"), + ), + packageName: "@mastra/core", + specifier: "@mastra/core/mastra", + }, + { + exportNames: ["Observability"], + modulePaths: MASTRA_OBSERVABILITY_ENTRY_PATHS, + packageName: "@mastra/observability", + specifier: "@mastra/observability", + }, + ], + specifiers: ["@mastra/core", "@mastra/core/mastra", "@mastra/observability"], + targets: ["node"], +}; + +const defaultTopLevelImportHooks: readonly TopLevelImportHook[] = [ + mastraTopLevelImportHook, +]; + +function toSafeLocalName(exportName: string, index: number): string { + return `__braintrust_export_${index}_${exportName.replace(/\W/g, "_")}`; +} diff --git a/js/src/instrumentation/braintrust-plugin.ts b/js/src/instrumentation/braintrust-plugin.ts index a1fe0e352..dc6c51e29 100644 --- a/js/src/instrumentation/braintrust-plugin.ts +++ b/js/src/instrumentation/braintrust-plugin.ts @@ -202,9 +202,8 @@ export class BraintrustPlugin extends BasePlugin { // Mastra is intentionally not wired here: `@mastra/core` ships its own // ObservabilityExporter contract, and `BraintrustObservabilityExporter` - // (wrappers/mastra.ts) is auto-installed by the loader patch in - // `auto-instrumentations/loader/mastra-observability-patch.ts` rather than - // by a BasePlugin / tracingChannel subscription. + // (wrappers/mastra.ts) is auto-installed by the top-level import hook + // registry rather than by a BasePlugin / tracingChannel subscription. } protected onDisable(): void { diff --git a/js/src/node/apply-auto-instrumentation-entry.ts b/js/src/node/apply-auto-instrumentation-entry.ts index 0328688dd..a6310eeb7 100644 --- a/js/src/node/apply-auto-instrumentation-entry.ts +++ b/js/src/node/apply-auto-instrumentation-entry.ts @@ -3,7 +3,18 @@ import { register } from "node:module"; import { pathToFileURL } from "node:url"; import { getDefaultAutoInstrumentationConfigs } from "../auto-instrumentations/configs/all"; import { ModulePatch } from "../auto-instrumentations/loader/cjs-patch"; +import { installMastraExporterFactory } from "../auto-instrumentations/loader/mastra-observability-patch"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "../auto-instrumentations/loader/top-level-export-patches"; +import { installNodeTopLevelExportPatches } from "../auto-instrumentations/loader/top-level-export-patches-node"; import { patchTracingChannel } from "../auto-instrumentations/patch-tracing-channel"; +import { + isInstrumentationIntegrationDisabled, + readDisabledInstrumentationEnvConfig, +} from "../instrumentation/config"; +import { BraintrustObservabilityExporter } from "../wrappers/mastra"; interface ApplyAutoInstrumentationState { applied?: boolean; @@ -33,8 +44,29 @@ if (!state.applied) { patchTracingChannel(diagnostics_channel.tracingChannel); const allConfigs = getDefaultAutoInstrumentationConfigs(); + const disabled = readDisabledInstrumentationEnvConfig( + process.env.BRAINTRUST_DISABLE_INSTRUMENTATION, + ).integrations; + if (!isInstrumentationIntegrationDisabled(disabled, "mastra")) { + installMastraExporterFactory(() => new BraintrustObservabilityExporter()); + } const currentModuleUrl = getCurrentModuleUrl(); + const topLevelImportHooks = getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }); + installTopLevelImportHookRunner(topLevelImportHooks); + installNodeTopLevelExportPatches({ + hooks: topLevelImportHooks, + importInTheMiddleHookSpecifier: + "./auto-instrumentations/import-in-the-middle/hook.mjs", + importInTheMiddleSpecifier: "./auto-instrumentations/import-in-the-middle", + parentURL: currentModuleUrl, + requireInTheMiddleSpecifier: + "./auto-instrumentations/require-in-the-middle", + }); + register("./auto-instrumentations/loader/esm-hook.mjs", { parentURL: currentModuleUrl, data: { instrumentations: allConfigs }, diff --git a/js/src/node/config.ts b/js/src/node/config.ts index 09c27b807..cb5e7a034 100644 --- a/js/src/node/config.ts +++ b/js/src/node/config.ts @@ -20,6 +20,10 @@ import { readDisabledInstrumentationEnvConfig, } from "../instrumentation/config"; import { installMastraExporterFactory } from "../auto-instrumentations/loader/mastra-observability-patch"; +import { + getDefaultTopLevelImportHooks, + installTopLevelImportHookRunner, +} from "../auto-instrumentations/loader/top-level-export-patches"; import { BraintrustObservabilityExporter } from "../wrappers/mastra"; const BRAINTRUST_ENV_SEARCH_PARENT_LIMIT = 64; @@ -136,15 +140,19 @@ export function configureNode() { _internalSetInitialState(); - // The Mastra patches rewritten by the bundler plugin and the loader hook - // both look up an exporter factory on `globalThis` at runtime. Register it - // here too so a bundled app (where neither hook.mjs nor the loader runs - // against `@mastra/core`) still gets the exporter installed when its code - // imports `braintrust`. The loader path (hook.mjs) also calls this; the - // `??=` makes double-registration a no-op. + // Bundled wrapper modules call the top-level import hook runner through + // `globalThis`, and Mastra's hook uses the exporter factory below. Register + // both when the app imports `braintrust`; the loader path (hook.mjs) does + // the same setup for zero-line instrumentation. const disabled = readDisabledInstrumentationEnvConfig( iso.getEnv("BRAINTRUST_DISABLE_INSTRUMENTATION"), ).integrations; + installTopLevelImportHookRunner( + getDefaultTopLevelImportHooks({ + disabledIntegrationConfig: disabled, + target: "node", + }), + ); if (!isInstrumentationIntegrationDisabled(disabled, "mastra")) { installMastraExporterFactory(() => new BraintrustObservabilityExporter()); } diff --git a/js/src/wrappers/mastra.ts b/js/src/wrappers/mastra.ts index 9946a9643..6542c1cc3 100644 --- a/js/src/wrappers/mastra.ts +++ b/js/src/wrappers/mastra.ts @@ -10,9 +10,9 @@ * Two integration paths: * - **Manual**: `new Mastra({ observability: new Observability({ configs: { * default: { exporters: [new BraintrustObservabilityExporter()] } } }) })` - * - **Auto** (under `node --import braintrust/hook.mjs`): the loader patches - * `@mastra/core`'s `dist/mastra/index.{js,cjs}` to wrap `Mastra` so it - * calls `defaultInstance.registerExporter(exporter)` after construction. + * - **Auto** (under `node --import braintrust/hook.mjs`): the top-level + * import hook registry wraps Mastra's exported constructors so a default + * Observability config receives this exporter automatically. * * Minimum supported Mastra version: 1.20.0 (when `Mastra.prototype.register` * `Exporter` and `ObservabilityInstance.registerExporter` were added). The @@ -370,7 +370,7 @@ function logExporterError(err: unknown): void { * * - **Auto-instrumentation**: run your app with * `node --import braintrust/hook.mjs`. The loader installs - * `BraintrustObservabilityExporter` into every `new Mastra(...)` + * `BraintrustObservabilityExporter` into Mastra Observability configs * automatically. * - **Manual wiring**: pass the exporter yourself: * diff --git a/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json new file mode 100644 index 000000000..84b0d6964 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mastra/core", + "version": "1.26.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./mastra": { + "import": "./dist/mastra/index.js", + "require": "./dist/mastra/index.cjs" + } + } +} diff --git a/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json new file mode 100644 index 000000000..ed647752e --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/node_modules/@mastra/observability/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mastra/observability", + "version": "1.26.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + } +} diff --git a/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs b/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs new file mode 100644 index 000000000..94c9ab6c0 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/runtime-apply-auto-mastra-top-level-esm.mjs @@ -0,0 +1,3 @@ +import "braintrust/apply-auto-instrumentation"; + +await import("./test-mastra-top-level-esm.mjs"); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js b/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js new file mode 100644 index 000000000..26a50077c --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-bundler.js @@ -0,0 +1,3 @@ +import { Mastra } from "@mastra/core"; + +new Mastra({}); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs new file mode 100644 index 000000000..0e761531f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-cjs.cjs @@ -0,0 +1,14 @@ +const { parentPort } = require("node:worker_threads"); +const { Mastra } = require("@mastra/core"); + +const mastra = new Mastra({}); +parentPort?.postMessage({ + result: { + exporters: + mastra.observability?.config?.configs?.default?.exporters?.map( + (exporter) => exporter.name, + ) ?? [], + hasObservability: Boolean(mastra.observability), + }, + type: "mastra-result", +}); diff --git a/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs new file mode 100644 index 000000000..e60a93761 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/test-mastra-top-level-esm.mjs @@ -0,0 +1,14 @@ +import { parentPort } from "node:worker_threads"; +import { Mastra } from "@mastra/core"; + +const mastra = new Mastra({}); +parentPort?.postMessage({ + result: { + exporters: + mastra.observability?.config?.configs?.default?.exporters?.map( + (exporter) => exporter.name, + ) ?? [], + hasObservability: Boolean(mastra.observability), + }, + type: "mastra-result", +}); diff --git a/js/tests/auto-instrumentations/loader-hook.test.ts b/js/tests/auto-instrumentations/loader-hook.test.ts index 51a20be41..77599fdde 100644 --- a/js/tests/auto-instrumentations/loader-hook.test.ts +++ b/js/tests/auto-instrumentations/loader-hook.test.ts @@ -28,6 +28,18 @@ const runtimeApplyAutoSideEffectCjsPath = path.join( fixturesDir, "runtime-apply-auto-side-effect-cjs.cjs", ); +const mastraTopLevelEsmPath = path.join( + fixturesDir, + "test-mastra-top-level-esm.mjs", +); +const mastraTopLevelCjsPath = path.join( + fixturesDir, + "test-mastra-top-level-cjs.cjs", +); +const runtimeApplyAutoMastraTopLevelEsmPath = path.join( + fixturesDir, + "runtime-apply-auto-mastra-top-level-esm.mjs", +); interface TestResult { events: { start: any[]; end: any[]; error: any[] }; @@ -83,6 +95,49 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.withResponseOk).toBe(true); expect(result.constructorName).toBe("HelperPromise"); }); + + it("should patch Mastra top-level ESM exports", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); + + it("should patch Mastra top-level CJS exports", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelCjsPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); + + it("should respect Mastra disable config for top-level export patches", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + env: { BRAINTRUST_DISABLE_INSTRUMENTATION: "mastra" }, + execArgv: ["--import", hookPath], + messageType: "mastra-result", + script: mastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(false); + expect(result.exporters).toEqual([]); + }); }); describe("apply-auto-instrumentation side-effect runtime setup", () => { @@ -126,6 +181,20 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.events.start.length).toBe(1); expect(result.events.end.length).toBe(1); }); + + it("should apply Mastra top-level export patches through the side-effect export", async () => { + const result = await runWithWorkerMessage<{ + exporters: string[]; + hasObservability: boolean; + }>({ + execArgv: [], + messageType: "mastra-result", + script: runtimeApplyAutoMastraTopLevelEsmPath, + }); + + expect(result.hasObservability).toBe(true); + expect(result.exporters).toEqual(["braintrust"]); + }); }); }); diff --git a/js/tests/auto-instrumentations/transformation.test.ts b/js/tests/auto-instrumentations/transformation.test.ts index fc402096b..31d82fec3 100644 --- a/js/tests/auto-instrumentations/transformation.test.ts +++ b/js/tests/auto-instrumentations/transformation.test.ts @@ -306,6 +306,42 @@ describe("Orchestrion Transformation Tests", () => { expect(output).not.toContain("TracingChannel"); }); + it("should apply node top-level export patches", async () => { + const { braintrustEsbuildPlugin } = + await import("../../src/auto-instrumentations/bundler/esbuild.js"); + + const entryPoint = path.join(fixturesDir, "test-mastra-bundler.js"); + const outfile = path.join(outputDir, "esbuild-mastra-bundle.js"); + + const result = await esbuild.build({ + absWorkingDir: fixturesDir, + bundle: true, + entryPoints: [entryPoint], + format: "esm", + logLevel: "error", + outfile, + platform: "node", + plugins: [braintrustEsbuildPlugin()], + preserveSymlinks: true, + write: true, + }); + + expect(result.errors).toHaveLength(0); + expect(fs.existsSync(outfile)).toBe(true); + + const output = fs.readFileSync(outfile, "utf-8"); + + expect(output).toContain("__braintrustTopLevelImportHookRunner"); + expect(output).toContain("__braintrustExports"); + expect(output.replace(/\\/g, "/")).not.toContain( + JSON.stringify( + path + .join(fixturesDir, "node_modules", "@mastra", "core") + .replace(/\\/g, "/"), + ), + ); + }); + it("should bundle dc-browser module when useDiagnosticChannelCompatShim is true", async () => { const { braintrustEsbuildPlugin } = await import("../../src/auto-instrumentations/bundler/esbuild.js"); @@ -342,6 +378,37 @@ describe("Orchestrion Transformation Tests", () => { // Should NOT import from external diagnostics_channel expect(output).not.toMatch(/from\s+["']diagnostics_channel["']/); }); + + it("should skip node-only top-level export patches for browser bundles", async () => { + const { braintrustEsbuildPlugin } = + await import("../../src/auto-instrumentations/bundler/esbuild.js"); + + const entryPoint = path.join(fixturesDir, "test-mastra-bundler.js"); + const outfile = path.join(outputDir, "esbuild-mastra-browser-bundle.js"); + + const result = await esbuild.build({ + absWorkingDir: fixturesDir, + bundle: true, + entryPoints: [entryPoint], + format: "esm", + logLevel: "error", + outfile, + platform: "browser", + plugins: [ + braintrustEsbuildPlugin({ useDiagnosticChannelCompatShim: true }), + ], + preserveSymlinks: true, + write: true, + }); + + expect(result.errors).toHaveLength(0); + expect(fs.existsSync(outfile)).toBe(true); + + const output = fs.readFileSync(outfile, "utf-8"); + + expect(output).not.toContain("__braintrustTopLevelImportHookRunner"); + expect(output).not.toContain("__braintrustOriginal"); + }); }); describe("vite", () => { @@ -570,6 +637,37 @@ describe("Orchestrion Transformation Tests", () => { expect(output).toContain("orchestrion:openai:chat.completions.create"); }); + it("should apply top-level export patches (turbopack loader-only mode)", async () => { + const { errors, output } = await runWebpackWithLoader({ + entry: path.join(fixturesDir, "test-mastra-bundler.js"), + output: { + path: outputDir, + filename: "turbopack-mastra-bundle.js", + library: { type: "module" }, + }, + experiments: { outputModule: true }, + mode: "development", + resolve: { modules: [nodeModulesDir, "node_modules"] }, + externals: { "node:module": "module node:module" }, + module: { + rules: [ + { + use: [ + { + loader: webpackLoaderPath, + options: { browser: false }, + }, + ], + }, + ], + }, + }); + + expect(errors).toHaveLength(0); + expect(output).toContain("__braintrustTopLevelImportHookRunner"); + expect(output).toContain("__braintrustExports"); + }); + it("should bundle dc-browser polyfill when browser: true (turbopack loader-only mode)", async () => { const { errors, output } = await runWebpackWithLoader({ entry: path.join(fixturesDir, "test-app.js"), From f3ade69f1ec59b6acf36b3b117c05183b76320c6 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 6 Jul 2026 11:44:27 +0200 Subject: [PATCH 7/7] bundle into our own hooks --- js/package.json | 2 +- ...copy-auto-instrumentation-vendor-hooks.mjs | 16 -- js/src/auto-instrumentations/hook.mts | 52 +++- .../import-in-the-middle/create-hook.mjs | 5 +- .../import-in-the-middle/create-hook.mjs.d.ts | 23 ++ .../import-in-the-middle/lib/get-exports.mjs | 17 +- .../import-in-the-middle/lib/register.d.ts | 23 ++ .../import-in-the-middle/lib/register.js | 28 +- .../loader/import-in-the-middle-runtime.ts | 158 +++++++++++ .../loader/mastra-observability-patch.ts | 4 +- .../loader/require-in-the-middle-runtime.ts | 266 ++++++++++++++++++ .../top-level-export-patches-node.test.ts | 184 ++++++++++++ .../loader/top-level-export-patches-node.ts | 125 +++++--- .../require-in-the-middle/index.d.ts | 8 + .../node/apply-auto-instrumentation-entry.ts | 22 +- .../fixtures/import-hook-query-mode.mjs | 15 + .../fixtures/vendor-hooks/iitm-async-app.mjs | 2 + .../vendor-hooks/iitm-async-setup.mjs | 25 +- .../fixtures/vendor-hooks/iitm-sync-app.mjs | 7 +- .../node_modules/cjs-reexport-leaf/index.cjs | 1 + .../cjs-reexport-leaf/package.json | 5 + .../cjs-reexport-target/index.cjs | 1 + .../node_modules/cjs-reexport-leaf/index.cjs | 1 + .../cjs-reexport-leaf/package.json | 5 + .../cjs-reexport-target/package.json | 8 + .../auto-instrumentations/loader-hook.test.ts | 29 ++ 26 files changed, 923 insertions(+), 109 deletions(-) delete mode 100644 js/scripts/copy-auto-instrumentation-vendor-hooks.mjs create mode 100644 js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts create mode 100644 js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts create mode 100644 js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts create mode 100644 js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts create mode 100644 js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts create mode 100644 js/src/auto-instrumentations/require-in-the-middle/index.d.ts create mode 100644 js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json create mode 100644 js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json diff --git a/js/package.json b/js/package.json index bafe65d1f..bf669af17 100644 --- a/js/package.json +++ b/js/package.json @@ -143,7 +143,7 @@ ], "scripts": { "postinstall": "node ./scripts/install.js", - "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup && node ./scripts/copy-auto-instrumentation-vendor-hooks.mjs", + "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup", "check:typings": "tsc --noEmit", "watch": "tsup --watch", "clean": "rm -r dist/* && rm -r dev/dist/* && rm -r util/dist/*", diff --git a/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs b/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs deleted file mode 100644 index bba72f3ff..000000000 --- a/js/scripts/copy-auto-instrumentation-vendor-hooks.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { cp, rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const jsRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const pairs = [ - ["import-in-the-middle", "import-in-the-middle"], - ["require-in-the-middle", "require-in-the-middle"], -]; - -for (const [sourceName, destName] of pairs) { - const source = join(jsRoot, "src", "auto-instrumentations", sourceName); - const dest = join(jsRoot, "dist", "auto-instrumentations", destName); - await rm(dest, { force: true, recursive: true }); - await cp(source, dest, { recursive: true }); -} diff --git a/js/src/auto-instrumentations/hook.mts b/js/src/auto-instrumentations/hook.mts index d8e62ffed..fa65e4a29 100644 --- a/js/src/auto-instrumentations/hook.mts +++ b/js/src/auto-instrumentations/hook.mts @@ -13,7 +13,7 @@ * - CJS modules: Transformed via ModulePatch monkey-patching Module._compile */ -import { register } from "node:module"; +import { register as registerModule } from "node:module"; import { isInstrumentationIntegrationDisabled, readDisabledInstrumentationEnvConfig, @@ -23,12 +23,29 @@ import { installMastraExporterFactory } from "./loader/mastra-observability-patc import { getDefaultAutoInstrumentationConfigs } from "./configs/all.js"; import { ModulePatch } from "./loader/cjs-patch.js"; import { patchTracingChannel } from "./patch-tracing-channel.js"; +import registerState from "./import-in-the-middle/lib/register.js"; +import { createHook as createImportInTheMiddleHook } from "./import-in-the-middle/create-hook.mjs"; import { getDefaultTopLevelImportHooks, installTopLevelImportHookRunner, } from "./loader/top-level-export-patches.js"; import { installNodeTopLevelExportPatches } from "./loader/top-level-export-patches-node.js"; +const BRAINTRUST_IITM_LOADER_PARAM = "braintrust-iitm-loader"; +const registryImportUrl = getCanonicalHookUrl(import.meta.url); +const asyncImportHookUrl = getImportInTheMiddleLoaderUrl(registryImportUrl); +const isImportInTheMiddleLoader = hasImportInTheMiddleLoaderParam( + import.meta.url, +); +const importInTheMiddleHook = createImportInTheMiddleHook(import.meta, { + registerUrl: registryImportUrl, +}); + +export const initialize = importInTheMiddleHook.initialize; +export const resolve = importInTheMiddleHook.resolve; +export const load = importInTheMiddleHook.load; +export const register = registerState.register; + const state = ((globalThis as any)[ Symbol.for("braintrust.applyAutoInstrumentation") ] ??= {}) as { applied?: boolean }; @@ -37,13 +54,13 @@ const alreadyApplied = state.applied; // Patch diagnostics_channel.tracePromise to handle APIPromise correctly. // MUST be done here (before any SDK code runs) to fix Anthropic APIPromise incompatibility. // Construct the module path dynamically to prevent build from stripping "node:" prefix. -if (!alreadyApplied) { +if (!isImportInTheMiddleLoader && !alreadyApplied) { const dcPath = ["node", "diagnostics_channel"].join(":"); const dc: any = await import(/* @vite-ignore */ dcPath as any); patchTracingChannel(dc.tracingChannel); } -if (!alreadyApplied) { +if (!isImportInTheMiddleLoader && !alreadyApplied) { const allConfigs = getDefaultAutoInstrumentationConfigs(); // Expose the Mastra exporter factory on globalThis so the loader patches @@ -63,15 +80,13 @@ if (!alreadyApplied) { }); installTopLevelImportHookRunner(topLevelImportHooks); installNodeTopLevelExportPatches({ + asyncImportHookUrl, hooks: topLevelImportHooks, - importInTheMiddleHookSpecifier: "./import-in-the-middle/hook.mjs", - importInTheMiddleSpecifier: "./import-in-the-middle", - parentURL: import.meta.url, - requireInTheMiddleSpecifier: "./require-in-the-middle", + registryImportUrl, }); // 1. Register ESM loader for ESM modules - register("./loader/esm-hook.mjs", { + registerModule("./loader/esm-hook.mjs", { parentURL: import.meta.url, data: { instrumentations: allConfigs }, } as any); @@ -100,3 +115,24 @@ if (!alreadyApplied) { } } } + +function hasImportInTheMiddleLoaderParam(url: string): boolean { + try { + return new URL(url).searchParams.has(BRAINTRUST_IITM_LOADER_PARAM); + } catch { + return false; + } +} + +function getCanonicalHookUrl(url: string): string { + const parsed = new URL(url); + parsed.searchParams.delete(BRAINTRUST_IITM_LOADER_PARAM); + parsed.hash = ""; + return parsed.href; +} + +function getImportInTheMiddleLoaderUrl(canonicalUrl: string): string { + const parsed = new URL(canonicalUrl); + parsed.searchParams.set(BRAINTRUST_IITM_LOADER_PARAM, "true"); + return parsed.href; +} diff --git a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs index 3ae4d53c3..778242514 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs +++ b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs @@ -292,9 +292,10 @@ function addIitm(url) { return urlObj.href; } -export function createHook(meta) { +export function createHook(meta, options = {}) { let cachedResolve; - const iitmURL = new URL("lib/register.js", meta.url).toString(); + const iitmURL = + options.registerUrl ?? new URL("lib/register.js", meta.url).toString(); const includeModules = new Set(); // Track CJS module URLs that IITM has wrapped. On Node 24+, CJS modules loaded diff --git a/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts new file mode 100644 index 000000000..a762ab4f9 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/create-hook.mjs.d.ts @@ -0,0 +1,23 @@ +export interface ImportInTheMiddleHook { + applyOptions(data: unknown): void; + initialize(data?: unknown): Promise; + load(url: string, context: unknown, parentLoad: Function): Promise; + loadSync(url: string, context: unknown, nextLoad: Function): unknown; + resolve( + specifier: string, + context: unknown, + parentResolve: Function, + ): Promise; + resolveSync( + specifier: string, + context: unknown, + nextResolve: Function, + ): unknown; +} + +export function createHook( + meta: { url: string }, + options?: { registerUrl?: string }, +): ImportInTheMiddleHook; + +export function supportsSyncHooks(): boolean; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs index 70920ce77..58d9b2b9b 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/get-exports.mjs @@ -154,6 +154,13 @@ const BUILT_INS = new Map(); let require; +function getRequire() { + if (!require) { + require = createRequire(import.meta.url); + } + return require; +} + // Returns a builtin's exports object. `process.getBuiltinModule` (Node >= // 20.16 / >= 22.3) bypasses registered loader hooks; `require` does not. Under // the in-thread `module.registerHooks` loader a plain `require(name)` here @@ -165,10 +172,7 @@ function loadBuiltin(name) { if (typeof process.getBuiltinModule === "function") { return process.getBuiltinModule(name); } - if (!require) { - require = createRequire(import.meta.url); - } - return require(name); + return getRequire()(name); } function getExportsForNodeBuiltIn(name) { @@ -284,11 +288,8 @@ function* getCjsExports(url, context, source) { [reUrl, reSpecifier] = resolved; } - if (!require) { - require = createRequire(import.meta.url); - } const newUrl = pathToFileURL( - require.resolve(reSpecifier, { + getRequire().resolve(reSpecifier, { paths: [dirname(fileURLToPath(reUrl))], }), ).href; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts b/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts new file mode 100644 index 000000000..229687dc8 --- /dev/null +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.d.ts @@ -0,0 +1,23 @@ +export type Namespace = Record; + +export function register( + name: string, + namespace: Namespace, + set: Record boolean>, + get: Record unknown>, + specifier?: string, +): void; + +declare const registerState: { + addHookedModules(modules: string[]): void; + deleteHookedModules(modules: string[]): void; + hookedModules: Set; + importHooks: Array< + (name: string, namespace: Namespace, specifier?: string) => void + >; + register: typeof register; + specifiers: Map; + toHook: Array<[name: string, namespace: Namespace, specifier?: string]>; +}; + +export default registerState; diff --git a/js/src/auto-instrumentations/import-in-the-middle/lib/register.js b/js/src/auto-instrumentations/import-in-the-middle/lib/register.js index 08eecf643..34b2670a5 100644 --- a/js/src/auto-instrumentations/import-in-the-middle/lib/register.js +++ b/js/src/auto-instrumentations/import-in-the-middle/lib/register.js @@ -2,13 +2,27 @@ // // This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc. -const importHooks = []; // TODO should this be a Set? -const setters = new WeakMap(); -const getters = new WeakMap(); -const specifiers = new Map(); -const toHook = []; -const hookedModuleCounts = new Map(); -const hookedModules = new Set(); +const state = (globalThis[ + Symbol.for("braintrust.importInTheMiddle.registerState") +] ??= { + getters: new WeakMap(), + hookedModuleCounts: new Map(), + hookedModules: new Set(), + importHooks: [], // TODO should this be a Set? + setters: new WeakMap(), + specifiers: new Map(), + toHook: [], +}); + +const { + getters, + hookedModuleCounts, + hookedModules, + importHooks, + setters, + specifiers, + toHook, +} = state; const proxyHandler = { set(target, name, value) { diff --git a/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts b/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts new file mode 100644 index 000000000..a19f82d87 --- /dev/null +++ b/js/src/auto-instrumentations/loader/import-in-the-middle-runtime.ts @@ -0,0 +1,158 @@ +import moduleDetailsFromPath from "module-details-from-path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import registerState from "../import-in-the-middle/lib/register.js"; + +type Namespace = Record; +type HookFn = (exported: Namespace, name: string, baseDir?: string) => unknown; + +const { + addHookedModules, + deleteHookedModules, + importHooks, + specifiers, + toHook, +} = registerState; + +function addHook( + hook: (name: string, namespace: Namespace, specifier?: string) => void, +) { + importHooks.push(hook); + toHook.forEach(([name, namespace, specifier]) => + hook(name, namespace, specifier), + ); +} + +function removeHook( + hook: (name: string, namespace: Namespace, specifier?: string) => void, +) { + const index = importHooks.indexOf(hook); + if (index > -1) { + importHooks.splice(index, 1); + } +} + +function callHookFn( + hookFn: HookFn, + namespace: Namespace, + name: string, + baseDir?: string, +): void { + const newDefault = hookFn(namespace, name, baseDir); + if (newDefault && newDefault !== namespace && "default" in namespace) { + namespace.default = newDefault; + } +} + +function normalizeModules(modules: string[]): string[] { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust import-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust import-in-the-middle only supports string module names or file URLs", + ); + } + } + + return modules; +} + +function moduleMatches( + matchArg: string, + name: string, + filePath: string | undefined, + specifier: string | undefined, + baseDir: string | undefined, + loadUrl: string, +): boolean { + if (filePath && matchArg === filePath) { + return true; + } + + if (matchArg === specifier || matchArg === loadUrl || matchArg === name) { + return true; + } + + if (!baseDir) { + return false; + } + + // Keep the top-level package check from upstream, but do not support the + // broad internals mode. Internal files must be listed explicitly. + return matchArg === name && baseDir.endsWith(specifiers.get(loadUrl) ?? ""); +} + +export default class Hook { + private readonly modules: string[]; + private readonly iitmHook: ( + name: string, + namespace: Namespace, + specifier?: string, + ) => void; + + constructor(modules: string[], hookFn: HookFn) { + modules = normalizeModules(modules); + if (typeof hookFn !== "function") { + throw new TypeError( + "Braintrust import-in-the-middle requires a hook function", + ); + } + + addHookedModules(modules); + + this.modules = modules; + this.iitmHook = (name, namespace, specifier) => { + const loadUrl = name; + let filePath: string | undefined; + let baseDir: string | undefined; + + if (loadUrl.startsWith("node:")) { + const unprefixed = name.slice(5); + if (isBuiltin(unprefixed)) { + name = unprefixed; + } + } else if (loadUrl.startsWith("file://")) { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 0; + try { + filePath = fileURLToPath(name); + name = filePath; + } catch {} + Error.stackTraceLimit = stackTraceLimit; + + if (filePath) { + const details = moduleDetailsFromPath(filePath); + if (details) { + name = details.name; + baseDir = details.basedir; + } + } + } + + for (const matchArg of modules) { + if ( + moduleMatches(matchArg, name, filePath, specifier, baseDir, loadUrl) + ) { + callHookFn( + hookFn, + namespace, + filePath && matchArg === filePath ? filePath : matchArg, + baseDir, + ); + } + } + }; + + addHook(this.iitmHook); + } + + unhook(): void { + removeHook(this.iitmHook); + deleteHookedModules(this.modules); + } +} diff --git a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts index 7df6eaa87..22879923e 100644 --- a/js/src/auto-instrumentations/loader/mastra-observability-patch.ts +++ b/js/src/auto-instrumentations/loader/mastra-observability-patch.ts @@ -54,9 +54,9 @@ export function patchMastraExports( context.moduleName === MASTRA_CORE_PACKAGE || context.moduleName === MASTRA_CORE_MASTRA_SPECIFIER ) { - return patchMastraCoreExports(exportsValue, context); + return patchMastraCoreExports(exportsValue, context) as T; } else if (context.moduleName === MASTRA_OBSERVABILITY_PACKAGE) { - return patchMastraObservabilityExports(exportsValue); + return patchMastraObservabilityExports(exportsValue) as T; } return exportsValue; } diff --git a/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts b/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts new file mode 100644 index 000000000..7dbc4d854 --- /dev/null +++ b/js/src/auto-instrumentations/loader/require-in-the-middle-runtime.ts @@ -0,0 +1,266 @@ +import moduleDetailsFromPath from "module-details-from-path"; +import Module, { builtinModules, createRequire } from "node:module"; +import { parse as parsePath } from "node:path"; +import { pathToFileURL } from "node:url"; + +type OnRequireFn = (exports: T, name: string, basedir?: string) => T; +type GetBuiltinModuleFn = (this: unknown, id: string) => unknown; +type ModuleWithInternals = typeof Module & { + _cache: Record) | undefined>; + _resolveFilename(id: string, parent: NodeJS.Module): string; +}; + +let builtinModulesSet: Set | undefined; +const ModuleInternals = Module as ModuleWithInternals; +const requireForResolve = createRequire( + pathToFileURL(process.argv[1] ?? process.cwd()).href, +); + +const isCore = + typeof Module.isBuiltin === "function" + ? Module.isBuiltin + : (moduleName: string) => { + if (moduleName.startsWith("node:")) { + return true; + } + + builtinModulesSet ??= new Set(builtinModules); + return builtinModulesSet.has(moduleName); + }; + +const normalize = /([/\\]index)?(\.js|\.cjs)?$/; + +class ExportsCache { + private readonly localCache = new Map(); + private readonly kRitmExports = Symbol("RitmExports"); + + has(filename: string, isBuiltin: boolean): boolean { + if (this.localCache.has(filename)) { + return true; + } else if (!isBuiltin) { + const mod = ModuleInternals._cache[filename] as + | (NodeJS.Module & Record) + | undefined; + return !!(mod && this.kRitmExports in mod); + } else { + return false; + } + } + + get(filename: string, isBuiltin: boolean): unknown { + const cachedExports = this.localCache.get(filename); + if (cachedExports !== undefined) { + return cachedExports; + } else if (!isBuiltin) { + const mod = ModuleInternals._cache[filename] as + | (NodeJS.Module & Record) + | undefined; + return mod && mod[this.kRitmExports]; + } + } + + set(filename: string, exports: unknown, isBuiltin: boolean): void { + if (isBuiltin) { + this.localCache.set(filename, exports); + } else if (filename in ModuleInternals._cache) { + ( + ModuleInternals._cache[filename] as NodeJS.Module & + Record + )[this.kRitmExports] = exports; + } else { + this.localCache.set(filename, exports); + } + } +} + +function normalizeModules(modules: string[]): string[] { + if (!Array.isArray(modules) || modules.length === 0) { + throw new TypeError( + "Braintrust require-in-the-middle requires a non-empty modules array", + ); + } + + for (const each of modules) { + if (typeof each !== "string") { + throw new TypeError( + "Braintrust require-in-the-middle only supports string module names or absolute paths", + ); + } + } + + return modules; +} + +export default class Hook { + private readonly cache = new ExportsCache(); + private readonly modules: string[]; + private readonly onrequire: OnRequireFn; + private unhooked = false; + private readonly origRequire = Module.prototype.require; + private readonly origGetBuiltinModule: GetBuiltinModuleFn | undefined = ( + process as unknown as { getBuiltinModule?: GetBuiltinModuleFn } + ).getBuiltinModule; + + constructor(modules: string[], onrequire: OnRequireFn) { + modules = normalizeModules(modules); + if (typeof onrequire !== "function") { + throw new TypeError( + "Braintrust require-in-the-middle requires an onrequire function", + ); + } + + if (typeof ModuleInternals._resolveFilename !== "function") { + throw new Error( + `Expected Module._resolveFilename to be a function, got ${typeof ModuleInternals._resolveFilename}`, + ); + } + + this.modules = modules; + this.onrequire = onrequire; + const self = this; + const patching = new Set(); + + Module.prototype.require = function (id: string) { + if (self.unhooked === true) { + return self.origRequire.apply(this, arguments as any); + } + + return patchedRequire.call(this, arguments, false); + }; + + if (typeof this.origGetBuiltinModule === "function") { + ( + process as unknown as { getBuiltinModule: GetBuiltinModuleFn } + ).getBuiltinModule = function (id: string) { + if (self.unhooked === true) { + return self.origGetBuiltinModule!.apply(process, arguments as any); + } + + return patchedRequire.call( + process as unknown as NodeJS.Module, + arguments, + true, + ); + }; + } + + function patchedRequire( + this: NodeJS.Module, + args: IArguments, + coreOnly: boolean, + ): unknown { + const id = args[0] as string; + const core = isCore(id); + let filename: string; + if (core) { + filename = id; + if (id.startsWith("node:")) { + const idWithoutPrefix = id.slice(5); + if (isCore(idWithoutPrefix)) { + filename = idWithoutPrefix; + } + } + } else if (coreOnly) { + return self.origGetBuiltinModule!.call(process, id); + } else { + try { + filename = ModuleInternals._resolveFilename(id, this); + } catch { + return self.origRequire.apply(this, args as any); + } + } + + if (self.cache.has(filename, core) === true) { + return self.cache.get(filename, core); + } + + const isPatching = patching.has(filename); + if (isPatching === false) { + patching.add(filename); + } + + const exports = coreOnly + ? self.origGetBuiltinModule!.call(process, id) + : self.origRequire.apply(this, args as any); + + if (isPatching === true) { + return exports; + } + + patching.delete(filename); + + let moduleName: string; + let basedir: string | undefined; + + if (core === true) { + if (modules.includes(filename) === false) { + return exports; + } + moduleName = filename; + } else if (modules.includes(filename)) { + const parsedPath = parsePath(filename); + moduleName = parsedPath.name; + basedir = parsedPath.dir; + } else { + const stat = moduleDetailsFromPath(filename); + if (!stat) { + return exports; + } + moduleName = stat.name; + basedir = stat.basedir; + + const fullModuleName = resolveModuleName(stat); + let matchFound = false; + if (!id.startsWith(".") && modules.includes(id)) { + moduleName = id; + matchFound = true; + } + + if ( + !modules.includes(moduleName) && + !modules.includes(fullModuleName) + ) { + return exports; + } + + if (modules.includes(fullModuleName) && fullModuleName !== moduleName) { + moduleName = fullModuleName; + matchFound = true; + } + + if (!matchFound) { + let res: string; + try { + res = requireForResolve.resolve(moduleName, { paths: [basedir] }); + } catch { + self.cache.set(filename, exports, core); + return exports; + } + + if (res !== filename) { + self.cache.set(filename, exports, core); + return exports; + } + } + } + + const patchedExports = onrequire(exports, moduleName, basedir); + self.cache.set(filename, patchedExports, core); + return patchedExports; + } + } + + unhook(): void { + this.unhooked = true; + Module.prototype.require = this.origRequire; + if (typeof this.origGetBuiltinModule === "function") { + ( + process as unknown as { getBuiltinModule: GetBuiltinModuleFn } + ).getBuiltinModule = this.origGetBuiltinModule; + } + } +} + +function resolveModuleName(stat: { name: string; path: string }): string { + return `${stat.name}/${stat.path.replace(normalize, "")}`; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts b/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts new file mode 100644 index 000000000..6bb7ad72b --- /dev/null +++ b/js/src/auto-instrumentations/loader/top-level-export-patches-node.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + installNodeTopLevelExportPatches, + type NodeTopLevelExportPatchRuntime, +} from "./top-level-export-patches-node"; +import { createHook as createImportInTheMiddleHook } from "../import-in-the-middle/create-hook.mjs"; +import type { TopLevelImportHook } from "./top-level-export-patches"; + +describe("installNodeTopLevelExportPatches", () => { + it("uses in-process registerHooks when synchronous hooks are supported", () => { + const events: string[] = []; + const importHook = { + initialize(data: unknown) { + events.push(`initialize:${JSON.stringify(data)}`); + return Promise.resolve(); + }, + async load() {}, + loadSync() {}, + async resolve() {}, + resolveSync() {}, + }; + + installNodeTopLevelExportPatches({ + asyncImportHookUrl: + "file:///braintrust/hook.mjs?braintrust-iitm-loader=true", + hooks: [fakeHook()], + registryImportUrl: "file:///braintrust/hook.mjs", + runtime: fakeRuntime(events, { + createImportHook(meta, options) { + events.push(`create:${meta.url}:${options.registerUrl}`); + return importHook; + }, + register() { + events.push("register"); + }, + registerHooks(hooks) { + events.push( + `registerHooks:${typeof hooks.resolve}:${typeof hooks.load}`, + ); + }, + supportsSyncHooks: () => true, + }), + }); + + expect(events).toEqual([ + "create:file:///braintrust/hook.mjs:file:///braintrust/hook.mjs", + 'initialize:{"include":["pkg"]}', + "registerHooks:function:function", + "importHook:pkg", + "importHookPatched:patched", + "requireHook:pkg", + "requireHookPatched:patched", + ]); + }); + + it("falls back to self-registering hook.mjs as the async loader", () => { + const events: string[] = []; + const asyncImportHookUrl = + "file:///braintrust/hook.mjs?braintrust-iitm-loader=true"; + + installNodeTopLevelExportPatches({ + asyncImportHookUrl, + hooks: [fakeHook()], + registryImportUrl: "file:///braintrust/hook.mjs", + runtime: fakeRuntime(events, { + register(specifier, options) { + events.push(`register:${specifier}:${JSON.stringify(options)}`); + }, + registerHooks() { + events.push("registerHooks"); + }, + supportsSyncHooks: () => false, + }), + }); + + expect(events).toEqual([ + `register:${asyncImportHookUrl}:{"data":{"include":["pkg"]}}`, + "importHook:pkg", + "importHookPatched:patched", + "requireHook:pkg", + "requireHookPatched:patched", + ]); + }); +}); + +describe("merged import-in-the-middle wrapper generation", () => { + it("imports register from the canonical Braintrust hook URL", async () => { + const hook = createImportInTheMiddleHook( + { url: "file:///braintrust/hook.mjs?braintrust-iitm-loader=true" }, + { registerUrl: "file:///braintrust/hook.mjs" }, + ); + await hook.initialize({ include: ["target"] }); + + const resolved = (await hook.resolve( + "target", + { conditions: ["import"], parentURL: "file:///app.mjs" }, + async () => ({ format: "module", url: "file:///target.mjs" }), + )) as { url: string }; + const loaded = (await hook.load( + resolved.url, + { format: "module" }, + async () => ({ + format: "module", + source: 'export const value = "original";', + }), + )) as { source: string }; + + expect(loaded.source).toContain( + "import { register } from 'file:///braintrust/hook.mjs'", + ); + expect(loaded.source).not.toContain("lib/register.js"); + }); +}); + +function fakeHook(): TopLevelImportHook { + return { + hook(exportsValue) { + (exportsValue as { value: string }).value = "patched"; + }, + integrations: ["mastra"], + specifiers: ["pkg"], + targets: ["node"], + }; +} + +function fakeRuntime( + events: string[], + { + createImportHook, + register, + registerHooks, + supportsSyncHooks, + }: { + createImportHook?: NodeTopLevelExportPatchRuntime["createImportHook"]; + register?: (specifier: string, options?: unknown) => void; + registerHooks?: (hooks: { load: unknown; resolve: unknown }) => void; + supportsSyncHooks: () => boolean; + }, +): Partial { + class FakeImportHook { + constructor( + modules: string[], + hookFn: (exportsValue: unknown, name: string) => unknown, + ) { + events.push(`importHook:${modules.join(",")}`); + const namespace = { value: "original" }; + hookFn(namespace, "pkg"); + events.push(`importHookPatched:${namespace.value}`); + } + } + class FakeRequireHook { + constructor( + modules: string[], + hookFn: (exportsValue: unknown, name: string) => unknown, + ) { + events.push(`requireHook:${modules.join(",")}`); + const namespace = { value: "original" }; + hookFn(namespace, "pkg"); + events.push(`requireHookPatched:${namespace.value}`); + } + } + return { + createImportHook: + createImportHook ?? + (() => ({ + initialize: () => Promise.resolve(), + async load() {}, + loadSync() {}, + async resolve() {}, + resolveSync() {}, + })), + importHookConstructor: FakeImportHook, + moduleApi: { + register: + register ?? + (() => { + events.push("register"); + }), + registerHooks, + }, + requireHookConstructor: FakeRequireHook, + supportsSyncHooks, + }; +} diff --git a/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts index 36771ae06..d7d262fea 100644 --- a/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts +++ b/js/src/auto-instrumentations/loader/top-level-export-patches-node.ts @@ -1,5 +1,12 @@ -import { createRequire, register } from "node:module"; +import * as module from "node:module"; import { debugLogger } from "../../debug-logger"; +import { + createHook as createImportInTheMiddleHook, + supportsSyncHooks, + type ImportInTheMiddleHook as ImportInTheMiddleHookApi, +} from "../import-in-the-middle/create-hook.mjs"; +import ImportInTheMiddleRuntimeHook from "./import-in-the-middle-runtime.js"; +import RequireInTheMiddleHook from "./require-in-the-middle-runtime.js"; import { getTopLevelImportHookSpecifiers, runTopLevelImportHooks, @@ -8,68 +15,94 @@ import { export interface InstallNodeTopLevelExportPatchesOptions { hooks: readonly TopLevelImportHook[]; - parentURL: string; - importInTheMiddleHookSpecifier: string; - importInTheMiddleSpecifier: string; - requireInTheMiddleSpecifier: string; + asyncImportHookUrl: string; + registryImportUrl: string; + runtime?: Partial; +} + +type HookCallback = ( + exportsValue: unknown, + name: string, + baseDir?: string, +) => unknown; + +type HookConstructor = new (modules: string[], hookFn: HookCallback) => unknown; + +interface NodeModuleApi { + register: (specifier: string, options?: unknown) => void; + registerHooks?: (hooks: { load: unknown; resolve: unknown }) => void; +} + +export interface NodeTopLevelExportPatchRuntime { + createImportHook: ( + meta: { url: string }, + options: { registerUrl: string }, + ) => ImportInTheMiddleHookApi; + importHookConstructor: HookConstructor; + moduleApi: NodeModuleApi; + requireHookConstructor: HookConstructor; + supportsSyncHooks: () => boolean; } export function installNodeTopLevelExportPatches({ + asyncImportHookUrl, hooks, - parentURL, - importInTheMiddleHookSpecifier, - importInTheMiddleSpecifier, - requireInTheMiddleSpecifier, + registryImportUrl, + runtime, }: InstallNodeTopLevelExportPatchesOptions): void { const specifiers = getTopLevelImportHookSpecifiers(hooks); if (specifiers.length === 0) return; - const requireFromParent = createRequire(parentURL); + const effectiveRuntime = getRuntime(runtime); + const hookCallback: HookCallback = (exportsValue, name, baseDir) => + runTopLevelImportHooks(hooks, exportsValue, { + baseDir, + moduleName: name, + }); try { - register(importInTheMiddleHookSpecifier, { - parentURL, - data: { include: specifiers }, - } as any); + if ( + effectiveRuntime.moduleApi.registerHooks && + effectiveRuntime.supportsSyncHooks() + ) { + const importHook = effectiveRuntime.createImportHook( + { url: registryImportUrl }, + { registerUrl: registryImportUrl }, + ); + void importHook.initialize({ include: specifiers }); + effectiveRuntime.moduleApi.registerHooks({ + load: importHook.loadSync, + resolve: importHook.resolveSync, + }); + } else { + effectiveRuntime.moduleApi.register(asyncImportHookUrl, { + data: { include: specifiers }, + }); + } - const iitm = requireFromParent(importInTheMiddleSpecifier) as { - Hook: new ( - modules: string[], - hookFn: ( - exportsValue: unknown, - name: string, - baseDir?: string, - ) => unknown, - ) => unknown; - }; - new iitm.Hook(specifiers, (exportsValue, name, baseDir) => - runTopLevelImportHooks(hooks, exportsValue, { - baseDir, - moduleName: name, - }), - ); + new effectiveRuntime.importHookConstructor(specifiers, hookCallback); } catch (err) { debugLogger.warn("Failed to install ESM top-level import hooks", err); } try { - const ritm = requireFromParent(requireInTheMiddleSpecifier) as { - Hook: new ( - modules: string[], - hookFn: ( - exportsValue: unknown, - name: string, - baseDir?: string, - ) => unknown, - ) => unknown; - }; - new ritm.Hook(specifiers, (exportsValue, name, baseDir) => - runTopLevelImportHooks(hooks, exportsValue, { - baseDir, - moduleName: name, - }), - ); + new effectiveRuntime.requireHookConstructor(specifiers, hookCallback); } catch (err) { debugLogger.warn("Failed to install CJS top-level import hooks", err); } } + +function getRuntime( + overrides: Partial | undefined, +): NodeTopLevelExportPatchRuntime { + return { + createImportHook: createImportInTheMiddleHook, + importHookConstructor: + ImportInTheMiddleRuntimeHook as unknown as HookConstructor, + moduleApi: module as unknown as NodeModuleApi, + requireHookConstructor: + RequireInTheMiddleHook as unknown as HookConstructor, + supportsSyncHooks, + ...overrides, + }; +} diff --git a/js/src/auto-instrumentations/require-in-the-middle/index.d.ts b/js/src/auto-instrumentations/require-in-the-middle/index.d.ts new file mode 100644 index 000000000..71421bb11 --- /dev/null +++ b/js/src/auto-instrumentations/require-in-the-middle/index.d.ts @@ -0,0 +1,8 @@ +export type OnRequireFn = (exports: T, name: string, basedir?: string) => T; + +export declare class Hook { + constructor(modules: string[], onrequire: OnRequireFn); + unhook(): void; +} + +export default Hook; diff --git a/js/src/node/apply-auto-instrumentation-entry.ts b/js/src/node/apply-auto-instrumentation-entry.ts index a6310eeb7..d30bb43d3 100644 --- a/js/src/node/apply-auto-instrumentation-entry.ts +++ b/js/src/node/apply-auto-instrumentation-entry.ts @@ -56,15 +56,15 @@ if (!state.applied) { disabledIntegrationConfig: disabled, target: "node", }); + const autoInstrumentationHookUrl = + getAutoInstrumentationHookUrl(currentModuleUrl); installTopLevelImportHookRunner(topLevelImportHooks); installNodeTopLevelExportPatches({ + asyncImportHookUrl: getImportInTheMiddleLoaderUrl( + autoInstrumentationHookUrl, + ), hooks: topLevelImportHooks, - importInTheMiddleHookSpecifier: - "./auto-instrumentations/import-in-the-middle/hook.mjs", - importInTheMiddleSpecifier: "./auto-instrumentations/import-in-the-middle", - parentURL: currentModuleUrl, - requireInTheMiddleSpecifier: - "./auto-instrumentations/require-in-the-middle", + registryImportUrl: autoInstrumentationHookUrl, }); register("./auto-instrumentations/loader/esm-hook.mjs", { @@ -103,4 +103,14 @@ function getCurrentModuleUrl(): string { return pathToFileURL(process.argv[1] ?? process.cwd()).href; } +function getAutoInstrumentationHookUrl(currentModuleUrl: string): string { + return new URL("./auto-instrumentations/hook.mjs", currentModuleUrl).href; +} + +function getImportInTheMiddleLoaderUrl(canonicalUrl: string): string { + const parsed = new URL(canonicalUrl); + parsed.searchParams.set("braintrust-iitm-loader", "true"); + return parsed.href; +} + export {}; diff --git a/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs b/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs new file mode 100644 index 000000000..3ad020cf0 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/import-hook-query-mode.mjs @@ -0,0 +1,15 @@ +import { parentPort } from "node:worker_threads"; + +const imported = await import(process.env.BRAINTRUST_QUERY_HOOK_URL); +const state = globalThis[Symbol.for("braintrust.applyAutoInstrumentation")]; + +parentPort?.postMessage({ + result: { + applied: state?.applied === true, + hasInitialize: typeof imported.initialize === "function", + hasLoad: typeof imported.load === "function", + hasRegister: typeof imported.register === "function", + hasResolve: typeof imported.resolve === "function", + }, + type: "hook-query-result", +}); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs index e6d69f304..b56411050 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-app.mjs @@ -2,9 +2,11 @@ import assert from "node:assert"; import getLabel, { foo } from "hook-target"; import getUnhookedLabel, { foo as unhookedFoo } from "unhooked-target"; import cjsTarget from "cjs-hook-target"; +import { nestedValue } from "cjs-reexport-target"; assert.equal(foo, 57); assert.equal(getLabel(), "patched"); assert.equal(unhookedFoo, 10); assert.equal(getUnhookedLabel(), "untouched"); assert.equal(cjsTarget.value, 8); +assert.equal(nestedValue, "nested"); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs index ee2eabea5..abbd89c92 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-async-setup.mjs @@ -16,21 +16,24 @@ const { addHookMessagePort, registerOptions, waitForAllMessagesAcknowledged } = register(fileURLToPath(hookUrl), import.meta.url, registerOptions); globalThis.__braintrustIitmAsyncHookCalls = 0; -const hook = new Hook(["hook-target", "cjs-hook-target"], (exports, name) => { - globalThis.__braintrustIitmAsyncHookCalls++; - if (name === "hook-target") { - exports.foo += 15; - exports.default = () => "patched"; - } - if (name === "cjs-hook-target") { - exports.default.value = 8; - } -}); +const hook = new Hook( + ["hook-target", "cjs-hook-target", "cjs-reexport-target"], + (exports, name) => { + globalThis.__braintrustIitmAsyncHookCalls++; + if (name === "hook-target") { + exports.foo += 15; + exports.default = () => "patched"; + } + if (name === "cjs-hook-target") { + exports.default.value = 8; + } + }, +); await waitForAllMessagesAcknowledged(); process.on("exit", () => { - assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 2); + assert.equal(globalThis.__braintrustIitmAsyncHookCalls, 3); hook.unhook(); addHookMessagePort.close(); }); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs index 9f4bb0012..9ee4c7878 100644 --- a/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/iitm-sync-app.mjs @@ -14,7 +14,7 @@ register(); let calls = 0; const hook = new Hook( - ["hook-target", "cjs-hook-target", "fs"], + ["hook-target", "cjs-hook-target", "cjs-reexport-target", "fs"], (exports, name) => { calls++; if (name === "hook-target") { @@ -33,6 +33,7 @@ const hook = new Hook( const target = await import("hook-target"); const other = await import("unhooked-target"); const cjsTarget = await import("cjs-hook-target"); +const cjsReexportTarget = await import("cjs-reexport-target"); const fs = await import("node:fs"); assert.equal(target.foo, 57); @@ -40,11 +41,13 @@ assert.equal(target.default(), "patched"); assert.equal(other.foo, 10); assert.equal(other.default(), "untouched"); assert.equal(cjsTarget.default.value, 8); +assert.equal(cjsReexportTarget.nestedValue, "nested"); +assert.equal(cjsReexportTarget.rootValue, undefined); assert.equal(fs.existsSync("/definitely/not/a/real/path"), true); const require = createRequire(import.meta.url); const requiredFs = require("fs"); assert.equal(Object.isExtensible(requiredFs), true); -assert.equal(calls, 3); +assert.equal(calls, 4); hook.unhook(); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs new file mode 100644 index 000000000..a8b25ed8f --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/index.cjs @@ -0,0 +1 @@ +exports.rootValue = "root"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json new file mode 100644 index 000000000..dbeb183ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-reexport-leaf", + "version": "1.0.0", + "main": "./index.cjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs new file mode 100644 index 000000000..22730cdc1 --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/index.cjs @@ -0,0 +1 @@ +module.exports = require("cjs-reexport-leaf"); diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs new file mode 100644 index 000000000..673c5564d --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/index.cjs @@ -0,0 +1 @@ +exports.nestedValue = "nested"; diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json new file mode 100644 index 000000000..dbeb183ab --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/node_modules/cjs-reexport-leaf/package.json @@ -0,0 +1,5 @@ +{ + "name": "cjs-reexport-leaf", + "version": "1.0.0", + "main": "./index.cjs" +} diff --git a/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json new file mode 100644 index 000000000..3e6393aca --- /dev/null +++ b/js/tests/auto-instrumentations/fixtures/vendor-hooks/node_modules/cjs-reexport-target/package.json @@ -0,0 +1,8 @@ +{ + "name": "cjs-reexport-target", + "version": "1.0.0", + "main": "./index.cjs", + "exports": { + ".": "./index.cjs" + } +} diff --git a/js/tests/auto-instrumentations/loader-hook.test.ts b/js/tests/auto-instrumentations/loader-hook.test.ts index 77599fdde..bccae5dcd 100644 --- a/js/tests/auto-instrumentations/loader-hook.test.ts +++ b/js/tests/auto-instrumentations/loader-hook.test.ts @@ -20,6 +20,10 @@ const helperPromisePath = path.join( fixturesDir, "test-api-promise-preservation.mjs", ); +const importHookQueryModePath = path.join( + fixturesDir, + "import-hook-query-mode.mjs", +); const runtimeApplyAutoSideEffectEsmPath = path.join( fixturesDir, "runtime-apply-auto-side-effect-esm.mjs", @@ -96,6 +100,31 @@ describe("Unified Loader Hook Integration Tests", () => { expect(result.constructorName).toBe("HelperPromise"); }); + it("should expose import hook exports in query mode without bootstrapping", async () => { + const result = await runWithWorkerMessage<{ + applied: boolean; + hasInitialize: boolean; + hasLoad: boolean; + hasRegister: boolean; + hasResolve: boolean; + }>({ + env: { + BRAINTRUST_QUERY_HOOK_URL: `${pathToFileURL(hookPath).href}?braintrust-iitm-loader=true`, + }, + execArgv: [], + messageType: "hook-query-result", + script: importHookQueryModePath, + }); + + expect(result).toEqual({ + applied: false, + hasInitialize: true, + hasLoad: true, + hasRegister: true, + hasResolve: true, + }); + }); + it("should patch Mastra top-level ESM exports", async () => { const result = await runWithWorkerMessage<{ exporters: string[];