Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercel
is_segment: false,
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.otel' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' },
[GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' },
[GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 },
[GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 },
Expand All @@ -49,7 +49,7 @@ it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercel
is_segment: false,
attributes: expect.objectContaining({
'sentry.op': { type: 'string', value: 'gen_ai.generate_content' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.otel' },
'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' },
[GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' },
}),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin({ autoInstrumentation: false })],
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"build": "wrangler deploy --dry-run",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview --port 38787",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod",
Expand All @@ -17,11 +17,13 @@
"ai": "^7.0.0"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.52.0",
"@playwright/test": "~1.56.0",
"@cloudflare/workers-types": "^4.20260426.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"typescript": "^5.5.2",
"wrangler": "4.86.0",
"vite": "7.3.2",
"wrangler": "^4.86.0",
"ws": "^8.18.3"
},
"volta": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const APP_PORT = 38787;

const config = getPlaywrightConfig(
{
startCommand: `pnpm dev --port ${APP_PORT}`,
startCommand: `pnpm preview`,
port: APP_PORT,
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ name = "cloudflare-vercelai-v7-compat"
main = "src/index.ts"
compatibility_date = "2026-04-26"
compatibility_flags = ["nodejs_compat"]

# `vite preview` takes no `--var` flag, so the DSN is declared here and read from
# the process environment the Playwright web server inherits.
[secrets]
required = ["E2E_TEST_DSN"]
7 changes: 1 addition & 6 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Integration } from '@sentry/core';
import { vercelAIIntegration } from './integrations/tracing/vercelai';
import { getBaseDefaultIntegrations, initWithDefaultIntegrations } from './baseSdk';
import type { CloudflareClient, CloudflareOptions } from './client';

Expand All @@ -11,11 +10,7 @@ import type { CloudflareClient, CloudflareOptions } from './client';
* `getBaseDefaultIntegrations`.
*/
export function getDefaultIntegrations(options: CloudflareOptions): Integration[] {
return [
...getBaseDefaultIntegrations(options),
// Subscribes to the `ai` SDK's native `node:diagnostics_channel` telemetry channel.
vercelAIIntegration(),
];
return getBaseDefaultIntegrations(options);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CustomTransform } from '../apmTypes';
import { parse } from 'meriyah';
import { subscriberExportForModule } from '../config/channel-integration-definitions';
import { MODULE_REGISTRATION_TRANSFORM } from '../config/registration-only';

// Tracks Program nodes we already injected into, so a package with several
// instrumented files (or several configs pointing at one file) is injected only
Expand Down Expand Up @@ -75,6 +76,10 @@ function moduleInjectedSnippet(
* per file. Requires `@apm-js-collab/code-transformer` >= 0.18.1, where
* built-ins dispatch through the override map and expose the originals on
* `state.transforms.defaults`.
*
* Also carries the registration-only operator, which splices the same snippet
* without any channel injection — for library versions whose tracing channels
* are native.
*/
export function moduleInjectedTransforms(
// A function is read per injected file — the webpack/Turbopack loader uses it
Expand All @@ -83,15 +88,12 @@ export function moduleInjectedTransforms(
// importing file's location).
importSpecifier?: string | (() => string | undefined),
): Record<string, CustomTransform> {
const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => {
const { moduleType, module, transforms } = state as {
const spliceModuleInjected = (state: unknown, program: unknown): void => {
const { moduleType, module } = state as {
moduleType?: string;
module?: { name?: string };
transforms: { defaults: { tracingChannelImport: CustomTransform } };
};

transforms.defaults.tracingChannelImport(state, program, parent, ancestry);

const node = program as ProgramNode;
if (injectedPrograms.has(node)) {
return;
Expand All @@ -116,5 +118,25 @@ export function moduleInjectedTransforms(
node.body.splice(directiveIndex + 1, 0, ...statements);
};

return { tracingChannelImport: injectModuleInjected };
const injectModuleInjected: CustomTransform = (state, program, parent, ancestry) => {
const { transforms } = state as {
transforms: { defaults: { tracingChannelImport: CustomTransform } };
};

transforms.defaults.tracingChannelImport(state, program, parent, ancestry);
spliceModuleInjected(state, program);
};

// Operator for registration-only configs (`transform` field, see
// `config/registration-only.ts`): dispatched INSTEAD of `traceSync`, so no
// channel is declared and no function is wrapped. Their `astQuery: 'Program'`
// matches only the file root, so `node` is the Program itself.
const injectRegistrationOnly: CustomTransform = (state, node) => {
spliceModuleInjected(state, node);
};

return {
tracingChannelImport: injectModuleInjected,
[MODULE_REGISTRATION_TRANSFORM]: injectRegistrationOnly,
};
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Build-time metadata mapping each instrumented package (orchestrion
* `module.name`) to the channel-subscriber integration that consumes the
* channels injected into it — by the `exportName` it is published under from
* `@sentry/server-utils/orchestrion`.
* Build-time metadata mapping each channel-subscriber integration — by the
* `exportName` it is published under from `@sentry/server-utils/orchestrion` —
* to the instrumented packages (orchestrion `module.name`) whose channels it
* consumes.
*
* Kept in a separate, factory-free module on purpose: the module-injected
* transform (reachable from every orchestrion bundler plugin) reads this to
Expand All @@ -20,6 +20,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [
{ exportName: 'postgresJsIntegration', modules: ['postgres'] },
{ exportName: 'mysqlIntegration', modules: ['mysql'] },
{ exportName: 'mysql2Integration', modules: ['mysql2'] },
{ exportName: 'mongooseIntegration', modules: ['mongoose'] },

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also needs to add: prisma, fastify, I suppose?

@JPeer264 JPeer264 Aug 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is a separate commit (and PR). There are way more missing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add the other things that are native here? I think these are missing here:

  Missing exportName  │                          module(s)                          │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ awsIntegration       │ @smithy/core, @smithy/smithy-client, @aws-sdk/smithy-client │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ koaIntegration       │ koa                                                         │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ knexIntegration      │ knex                                                        │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ langChainIntegration │ @langchain/core, @langchain/openai, …                       │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ langGraphIntegration │ @langchain/langgraph                                        │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ mongoIntegration     │ mongodb                                                     │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ mongooseIntegration  │ mongoose                                                    │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ tediousIntegration   │ tedious                                                     │
├──────────────────────┼─────────────────────────────────────────────────────────────┤
│ firebaseIntegration  │ @firebase/firestore, firebase-functions

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they are missing. I tried to split it up in another PR to keep it small. mongoose is only in this PR since it has native channels already.

That is the follow up: #23477

{ exportName: 'genericPoolIntegration', modules: ['generic-pool'] },
{ exportName: 'lruMemoizerIntegration', modules: ['lru-memoizer'] },
{ exportName: 'openAIIntegration', modules: ['openai'] },
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/ioredis.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { InstrumentationConfig } from '..';
import { getModuleNames } from './module-names';
import { registrationOnly } from './registration-only';

export const ioredisConfig = [
registrationOnly({ name: 'ioredis', versionRange: '>=5.11.0', filePath: 'built/Redis.js' }),
// ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel)
...['lib/redis.js', 'built/redis.js', 'built/redis/index.js'].flatMap((filePath): InstrumentationConfig[] => [
{
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/mongoose.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { InstrumentationConfig } from '..';
import { getModuleNames } from './module-names';
import { registrationOnly } from './registration-only';

// mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by
// `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting
Expand Down Expand Up @@ -37,6 +38,7 @@ const CONTEXT_CAPTURE_QUERY_METHODS = [
] as const;

export const mongooseConfig = [
registrationOnly({ name: 'mongoose', versionRange: '>=9.7.0', filePath: 'lib/query.js' }),
// Query execution
// the span for most read/write operations. `op`, collection and model are
// read off the `Query` at exec time.
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/mysql2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { InstrumentationConfig } from '..';
import { getModuleNames } from './module-names';
import { registrationOnly } from './registration-only';

// Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection
// prototype) to orchestrion channel injection.
Expand All @@ -21,6 +22,7 @@ import { getModuleNames } from './module-names';
// guard) — so `Auto` would crash streamed queries. `Callback` leaves that shape untouched (a rare,
// row-streaming use that consumes the emitter's events), the tradeoff being it isn't traced.
export const mysql2Config = [
registrationOnly({ name: 'mysql2', versionRange: '>=3.20.0', filePath: 'lib/base/connection.js' }),
{
channelName: 'query',
module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' },
Expand Down
2 changes: 2 additions & 0 deletions packages/server-utils/src/orchestrion/config/redis.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { InstrumentationConfig } from '..';
import { getModuleNames } from './module-names';
import { registrationOnly } from './registration-only';

export const redisConfig = [
registrationOnly({ name: '@redis/client', versionRange: '>=5.12.0', filePath: 'dist/lib/client/index.js' }),
// redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an
// anonymous prototype assignment (`expressionName`); it settles via the nested
// `command_obj.callback`, so `kind: 'Sync'` and the subscriber wraps that callback.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { InstrumentationConfig } from '..';

/**
* Name of the custom transform (see `bundler/moduleInjectedTransform.ts`) that
* splices only the module-injected registration snippet — no channel machinery.
* Prefixed so it can never shadow a built-in operator (`traceSync`,
* `tracingChannelImport`, ...), which the custom-transforms map overrides by
* name.
*/
export const MODULE_REGISTRATION_TRANSFORM = 'sentryModuleRegistration';

/**
* Build a registration-only config for one file of a library whose tracing
* channels are native (published by the library itself): no channels are
* injected, but transforming the file registers the module's channel-subscriber
* integration at evaluation time. `astQuery: 'Program'` matches the file root
* unconditionally, so no anchor function inside the library needs to exist.
*/
export function registrationOnly(module: {
name: string;
versionRange: string;
filePath: string;
}): InstrumentationConfig {
return {
channelName: 'module-registration',
module,
astQuery: 'Program',
transform: MODULE_REGISTRATION_TRANSFORM,
};
}
4 changes: 4 additions & 0 deletions packages/server-utils/src/orchestrion/config/vercel-ai.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { InstrumentationConfig } from '..';
import { getModuleNames } from './module-names';
import { registrationOnly } from './registration-only';

export const vercelAiConfig = [
...['dist/index.js', 'dist/index.mjs'].map(filePath =>
registrationOnly({ name: 'ai', versionRange: '>=7.0.0', filePath }),
),
// Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting
// channels into the top-level entry points. `resolveLanguageModel` is wrapped
// not to span it, but so the subscriber can monkey-patch `doGenerate`/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/optio

// The code transformer reads the instrumented package's version from its
// on-disk `package.json`, so each test package needs a real directory.
function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs'): void {
function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs', subdir = 'lib'): void {
const dir = join(root, 'node_modules', name);
mkdirSync(join(dir, 'lib'), { recursive: true });
mkdirSync(join(dir, subdir), { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, ...(type ? { type } : {}) }));
}

Expand Down Expand Up @@ -44,6 +44,8 @@ describe('module-injected transform', () => {
makePackage(root, 'mysql', '2.18.1', 'commonjs');
makePackage(root, 'pg', '8.11.0', 'module');
makePackage(root, 'my-lib', '1.0.0', 'commonjs');
makePackage(root, 'ioredis', '5.11.0', 'commonjs', 'built');
makePackage(root, 'ai', '7.0.0', 'module', 'dist');
});

afterAll(() => {
Expand Down Expand Up @@ -124,6 +126,38 @@ describe('module-injected transform', () => {
expect(calls).toHaveLength(1);
});

it('injects only the registration snippet for a CJS version with native channels', () => {
const t = createCodeTransformer(orchestrionTransformOptions({}));
// No anchor function present — registration must not depend on any library
// internals existing (`astQuery: 'Program'` matches the file root).
const code = "'use strict';\nclass Redis {}\nmodule.exports = Redis;\n";
const result = t.transform(code, join(root, 'node_modules/ioredis/built/Redis.js'));

expect(result).not.toBeNull();
expect(result!.code).toMatch(
/const\s*\{\s*orchestrionModuleInjected,\s*redisIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/,
);
expect(result!.code).toContain('orchestrionModuleInjected("ioredis", redisIntegration)');
// The library publishes its own channels, so nothing else is injected: no
// diagnostics_channel import, no channel declaration, no function wrapper.
expect(result!.code).not.toContain('diagnostics_channel');
expect(result!.code).not.toContain('tr_ch_apm');
expect(result!.code).toContain('class Redis');
});

it('injects only the registration snippet for an ESM version with native channels', () => {
const t = createCodeTransformer(orchestrionTransformOptions({}));
const result = t.transform('export const embed = () => {};\n', join(root, 'node_modules/ai/dist/index.mjs'));

expect(result).not.toBeNull();
expect(result!.code).toMatch(
/import\s*\{\s*orchestrionModuleInjected,\s*vercelAIIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/,
);
expect(result!.code).toContain('orchestrionModuleInjected("ai", vercelAIIntegration)');
expect(result!.code).not.toContain('diagnostics_channel');
expect(result!.code).not.toContain('tr_ch_apm');
});

it('honors a custom import specifier (Turbopack passes an absolute path)', () => {
const t = createCodeTransformer({
...orchestrionTransformOptions({}),
Expand Down
Loading