diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4066a18eefe2..46d6e7d4fac9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1168,6 +1168,7 @@ jobs: job_lint, job_check_format, job_circular_dep_check, + job_size_check, ] # Always run this, even if a dependent job failed if: always() diff --git a/.size-limit.js b/.size-limit.js index 9cebd30285e4..7106f2e29b03 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '156 KB', + limit: '158 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9b82b9bc8e..d91a753f6544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.22.0 + +### Important Changes + +- **feat(node): Instrument cloud functions for firebase v2 ([#17952](https://github.com/getsentry/sentry-javascript/pull/17952))** + + We added instrumentation for Cloud Functions for Firebase v2, enabling automatic performance tracking and error monitoring. This will be added automatically if you have enabled tracing. + +- **feat(core): Instrument LangChain AI ([#17955](https://github.com/getsentry/sentry-javascript/pull/17955))** + + Instrumentation was added for LangChain AI operations. You can configure what is recorded like this: + + ```ts + Sentry.init({ + integrations: [ + Sentry.langChainIntegration({ + recordInputs: true, // Record prompts/messages + recordOutputs: true, // Record responses + }), + ], + }); + ``` + +### Other Changes + +- feat(cloudflare,vercel-edge): Add support for LangChain instrumentation ([#17986](https://github.com/getsentry/sentry-javascript/pull/17986)) +- feat: Align sentry origin with documentation ([#17998](https://github.com/getsentry/sentry-javascript/pull/17998)) +- feat(core): Truncate request messages in AI integrations ([#17921](https://github.com/getsentry/sentry-javascript/pull/17921)) +- feat(nextjs): Support node runtime on proxy files ([#17995](https://github.com/getsentry/sentry-javascript/pull/17995)) +- feat(node): Pass requestHook and responseHook option to OTel ([#17996](https://github.com/getsentry/sentry-javascript/pull/17996)) +- fix(core): Fix wrong async types when instrumenting anthropic's stream api ([#18007](https://github.com/getsentry/sentry-javascript/pull/18007)) +- fix(nextjs): Remove usage of chalk to avoid runtime errors ([#18010](https://github.com/getsentry/sentry-javascript/pull/18010)) +- fix(node): Pino capture serialized `err` ([#17999](https://github.com/getsentry/sentry-javascript/pull/17999)) +- fix(node): Pino child loggers ([#17934](https://github.com/getsentry/sentry-javascript/pull/17934)) +- fix(react): Don't trim index route `/` when getting pathname ([#17985](https://github.com/getsentry/sentry-javascript/pull/17985)) +- fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions ([#17962](https://github.com/getsentry/sentry-javascript/pull/17962)) + +
+ Internal Changes + +- chore: Add required size_check for GH Actions ([#18009](https://github.com/getsentry/sentry-javascript/pull/18009)) +- chore: Upgrade madge to v8 ([#17957](https://github.com/getsentry/sentry-javascript/pull/17957)) +- test(hono): Fix hono e2e tests ([#18000](https://github.com/getsentry/sentry-javascript/pull/18000)) +- test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility ([#18013](https://github.com/getsentry/sentry-javascript/pull/18013)) +- test(react): Add parameterized route tests for `createHashRouter` ([#17789](https://github.com/getsentry/sentry-javascript/pull/17789)) + +
+ ## 10.21.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 442800456f9b..dd4bd7e8ebc3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -30,7 +30,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.trace 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.trace {} {}', type: 'string' }, @@ -45,7 +45,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.debug 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.debug {} {}', type: 'string' }, @@ -60,7 +60,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.log 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.log {} {}', type: 'string' }, @@ -75,7 +75,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.info 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.info {} {}', type: 'string' }, @@ -90,7 +90,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.warn 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.warn {} {}', type: 'string' }, @@ -105,7 +105,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.error 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.error {} {}', type: 'string' }, @@ -120,7 +120,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Assertion failed: console.assert 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -132,7 +132,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object: {"key":"value","nested":{"prop":123}}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Object: {}', type: 'string' }, @@ -146,7 +146,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Array: [1,2,3,"string"]', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Array: {}', type: 'string' }, @@ -160,7 +160,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Mixed: prefix {"obj":true} [4,5,6] suffix', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, @@ -177,7 +177,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: '', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -189,7 +189,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'String substitution %s %d test 42', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -201,7 +201,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object substitution %o {"key":"value"}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -213,7 +213,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'first 0 1 2', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'first {} {} {}', type: 'string' }, @@ -229,7 +229,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'hello true null undefined', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts new file mode 100644 index 000000000000..0d59fd91c2b7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/cloudflare'; +import { MockChain, MockChatModel, MockTool } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + // Create LangChain callback handler + const callbackHandler = Sentry.createLangChainCallbackHandler({ + recordInputs: false, + recordOutputs: false, + }); + + // Test 1: Chat model invocation + const chatModel = new MockChatModel({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + }); + + await chatModel.invoke('Tell me a joke', { + callbacks: [callbackHandler], + }); + + // Test 2: Chain invocation + const chain = new MockChain('my_test_chain'); + await chain.invoke( + { input: 'test input' }, + { + callbacks: [callbackHandler], + }, + ); + + // Test 3: Tool invocation + const tool = new MockTool('search_tool'); + await tool.call('search query', { + callbacks: [callbackHandler], + }); + + return new Response(JSON.stringify({ success: true })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts new file mode 100644 index 000000000000..946ae8252dbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts @@ -0,0 +1,197 @@ +// Mock LangChain types and classes for testing the callback handler + +// Minimal callback handler interface to match LangChain's callback handler signature +export interface CallbackHandler { + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[] | Record, + metadata?: Record, + runName?: string, + ) => unknown; + handleLLMEnd?: (output: unknown, runId: string) => unknown; + handleChainStart?: (chain: { name?: string }, inputs: Record, runId: string) => unknown; + handleChainEnd?: (outputs: unknown, runId: string) => unknown; + handleToolStart?: (tool: { name?: string }, input: string, runId: string) => unknown; + handleToolEnd?: (output: unknown, runId: string) => unknown; +} + +export interface LangChainMessage { + role: string; + content: string; +} + +export interface LangChainLLMResult { + generations: Array< + Array<{ + text: string; + generationInfo?: Record; + }> + >; + llmOutput?: { + tokenUsage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + }; +} + +export interface InvocationParams { + model: string; + temperature?: number; + maxTokens?: number; +} + +// Mock LangChain Chat Model +export class MockChatModel { + private _model: string; + private _temperature?: number; + private _maxTokens?: number; + + public constructor(params: InvocationParams) { + this._model = params.model; + this._temperature = params.temperature; + this._maxTokens = params.maxTokens; + } + + public async invoke( + messages: LangChainMessage[] | string, + options?: { callbacks?: CallbackHandler[] }, + ): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Get invocation params to match LangChain's signature + const invocationParams = { + model: this._model, + temperature: this._temperature, + max_tokens: this._maxTokens, + }; + + // Create serialized representation similar to LangChain + const serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'anthropic', 'anthropic'], // Third element is used as system provider + kwargs: invocationParams, + }; + + // Call handleChatModelStart + // Pass tags as a record with invocation_params for proper extraction + // The callback handler's getInvocationParams utility accepts both string[] and Record + for (const callback of callbacks) { + if (callback.handleChatModelStart) { + await callback.handleChatModelStart( + serialized, + messages, + runId, + undefined, + undefined, + { invocation_params: invocationParams }, + { ls_model_name: this._model, ls_provider: 'anthropic' }, + ); + } + } + + // Create mock result + const result: LangChainLLMResult = { + generations: [ + [ + { + text: 'Mock response from LangChain!', + generationInfo: { + finish_reason: 'stop', + }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + }, + }; + + // Call handleLLMEnd + for (const callback of callbacks) { + if (callback.handleLLMEnd) { + await callback.handleLLMEnd(result, runId); + } + } + + return result; + } +} + +// Mock LangChain Chain +export class MockChain { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async invoke( + inputs: Record, + options?: { callbacks?: CallbackHandler[] }, + ): Promise> { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleChainStart + for (const callback of callbacks) { + if (callback.handleChainStart) { + await callback.handleChainStart({ name: this._name }, inputs, runId); + } + } + + const outputs = { result: 'Chain execution completed!' }; + + // Call handleChainEnd + for (const callback of callbacks) { + if (callback.handleChainEnd) { + await callback.handleChainEnd(outputs, runId); + } + } + + return outputs; + } +} + +// Mock LangChain Tool +export class MockTool { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async call(input: string, options?: { callbacks?: CallbackHandler[] }): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleToolStart + for (const callback of callbacks) { + if (callback.handleToolStart) { + await callback.handleToolStart({ name: this._name }, input, runId); + } + } + + const output = `Tool ${this._name} executed with input: ${input}`; + + // Call handleToolEnd + for (const callback of callbacks) { + if (callback.handleToolEnd) { + await callback.handleToolEnd(output, runId); + } + } + + return output; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..875b4191b84b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not break in our +// cloudflare SDK. + +it('traces langchain chat model, chain, and tool invocations', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // Chat model span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + }), + // Chain span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.invoke_agent', + 'langchain.chain.name': 'my_test_chain', + }), + description: 'chain my_test_chain', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langchain', + }), + // Tool span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.execute_tool', + 'gen_ai.tool.name': 'search_tool', + }), + description: 'execute_tool search_tool', + op: 'gen_ai.execute_tool', + origin: 'auto.ai.langchain', + }), + ]), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index a8fe024e9405..b005398a5faf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", "@cloudflare/workers-types": "^4.20250521.0", + "typescript": "^5.9.3", "vitest": "3.1.0", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json index e5d6f2b66f33..3c1c64b66cb8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "lib": ["ESNext"], "jsx": "react-jsx", + "types": ["@cloudflare/workers-types/experimental"], "jsxImportSource": "hono/jsx" }, "include": ["src/**/*"], diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 2da23b152807..af9f306f017d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -16,6 +16,8 @@ "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" @@ -25,7 +27,7 @@ "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.0.0-beta.0", + "next": "16.0.0", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", @@ -50,6 +52,15 @@ "build-command": "pnpm test:build-webpack", "label": "nextjs-16 (webpack)", "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest-webpack", + "label": "nextjs-16 (latest, webpack)", + "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest", + "label": "nextjs-16 (latest, turbopack)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 4ed289eb6215..aa4611fb7afc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { @@ -13,8 +14,8 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); @@ -22,6 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { }); test('Faulty middlewares', async ({ request }) => { + test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET'; }); @@ -36,27 +38,29 @@ test('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); - await test.step('should record exceptions', async () => { - const errorEvent = await errorEventPromise; - - // Assert that isolation scope works properly - expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); - expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect([ - 'middleware GET', // non-otel webpack versions - '/middleware', // middleware file - '/proxy', // proxy file - ]).toContain(errorEvent.transaction); - }); + // TODO: proxy errors currently not reported via onRequestError + // await test.step('should record exceptions', async () => { + // const errorEvent = await errorEventPromise; + + // // Assert that isolation scope works properly + // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // expect([ + // 'middleware GET', // non-otel webpack versions + // '/middleware', // middleware file + // '/proxy', // proxy file + // ]).toContain(errorEvent.transaction); + // }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware GET' && @@ -74,18 +78,26 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru expect.arrayContaining([ { data: { - 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', 'http.response.status_code': 200, - type: 'fetch', - url: 'http://localhost:3030/', - 'http.url': 'http://localhost:3030/', - 'server.address': 'localhost:3030', + 'network.peer.address': '::1', + 'network.peer.port': 3030, + 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.wintercg_fetch', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'server.address': 'localhost', + 'server.port': 3030, + url: 'http://localhost:3030/', + 'url.full': 'http://localhost:3030/', + 'url.path': '/', + 'url.query': '', + 'url.scheme': 'http', + 'user_agent.original': 'node', }, description: 'GET http://localhost:3030/', op: 'http.client', - origin: 'auto.http.wintercg_fetch', + origin: 'auto.http.otel.node_fetch', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -95,11 +107,12 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru }, ]), ); + expect(middlewareTransaction.breadcrumbs).toEqual( expect.arrayContaining([ { - category: 'fetch', - data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + category: 'http', + data: { 'http.method': 'GET', status_code: 200, url: 'http://localhost:3030/' }, timestamp: expect.any(Number), type: 'http', }, diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc deleted file mode 100644 index 47e4665f6905..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "sentry-firebase-e2e-test-f4ed3" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md index e44ee12f5268..bd91bd5a872a 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/README.md +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -1,64 +1,50 @@ -## Assuming you already have installed docker desktop or orbstack etc. or any other docker software +
+ + Firebase + +
-### Enabling / authorising firebase emulator through docker +## Description -1. Run the docker +[Firebase](https://firebase.google.com/) starter repository with Cloud Functions for Firebase and Firestore. -```bash -pnpm docker -``` - -2. In new tab, enter the docker container by simply running +## Project setup -```bash -docker exec -it sentry-firebase bash +```sh +$ pnpm install ``` -3. Now inside docker container run +## Compile and run the project -```bash -firebase login +```sh +$ pnpm dev # builds the functions and firestore app +$ pnpm emulate +$ pnpm start # run the firestore app ``` -4. You should now see a long link to authenticate with google account, copy the link and open it using your browser -5. Choose the account you want to authenticate with -6. Once you do this you should be able to see something like "Firebase CLI Login Successful" -7. And inside docker container you should see something like "Success! Logged in as " -8. Now you can exit docker container - -```bash -exit -``` +## Run tests -9. Switch back to previous tab, stop the docker container (ctrl+c). -10. You should now be able to run the test, as you have correctly authenticated the firebase emulator +Either run the tests directly: -### Preparing data for CLI - -1. Please authorize the docker first - see the previous section -2. Once you do that you can generate .env file locally, to do that just run - -```bash -npm run createEnvFromConfig +```sh +$ pnpm test:build +$ pnpm test:assert ``` -3. It will create a new file called ".env" inside folder "docker" -4. View the file. There will be 2 params CONFIG_FIREBASE_TOOLS and CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS. -5. Now inside the CLI create a new variable under the name CONFIG_FIREBASE_TOOLS and - CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS - take values from mentioned .env file -6. File .env is ignored to avoid situation when developer after authorizing firebase with private account will - accidently push the tokens to github. -7. But if we want the users to still have some default to be used for authorisation (on their local development) it will - be enough to commit this file, we just have to authorize it with some "special" account. +Or run develop while running the tests directly against the emulator. Start each script in a separate terminal: -**Some explanation towards environment settings, the environment variable defined directly in "environments" takes -precedence over .env file, that means it will be safe to define it in CLI and still keeps the .env file.** +```sh +$ pnpm dev +$ pnpm emulate +$ pnpm test --ui +``` -### Scripts - helpers +The tests will run against the Firebase Emulator Suite. -- createEnvFromConfig - it will use the firebase docker authentication and create .env file which will be used then by - docker whenever you run emulator -- createConfigFromEnv - it will use '.env' file in docker folder to create .config for the firebase to be used to - authenticate whenever you run docker, Docker by default loads .env file itself +## Resources -Use these scripts when testing and updating the environment settings on CLI +- [Firebase](https://firebase.google.com/) +- [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) +- [Firebase SDK](https://firebase.google.com/docs/sdk) +- [Firebase Functions](https://firebase.google.com/docs/functions) +- [Firestore](https://firebase.google.com/docs/firestore) diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json index 05203f1d6567..eb1b42b8aa9c 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -16,5 +16,12 @@ "enabled": true }, "singleProjectMode": true - } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"] + } + ] } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json new file mode 100644 index 000000000000..b5d19993bdae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "firestore-app", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "start": "node ./dist/app.js" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "express": "^4.18.2", + "firebase": "^12.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/node": "^22.13.14", + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json new file mode 100644 index 000000000000..c3be318b8c38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json @@ -0,0 +1,19 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch" + }, + "engines": { + "node": "20" + }, + "main": "dist/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "@sentry/node": "latest || *" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts new file mode 100644 index 000000000000..6a3df6f4a61a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts @@ -0,0 +1,50 @@ +import './init'; + +import { onDocumentCreated, onDocumentCreatedWithAuthContext } from 'firebase-functions/firestore'; +import { onRequest } from 'firebase-functions/https'; +import * as logger from 'firebase-functions/logger'; +import { setGlobalOptions } from 'firebase-functions/options'; +import * as admin from 'firebase-admin'; + +setGlobalOptions({ region: 'default' }); + +admin.initializeApp(); + +const db = admin.firestore(); + +export const helloWorld = onRequest(async (request, response) => { + logger.info('Hello logs!', { structuredData: true }); + + response.send('Hello from Firebase!'); +}); + +export const unhandeledError = onRequest(async (request, response) => { + throw new Error('There is an error!'); +}); + +export const onCallSomething = onRequest(async (request, response) => { + const data = { + name: request.body?.name || 'Sample Document', + timestamp: performance.now(), + description: request.body?.description || 'Created via Cloud Function', + }; + + await db.collection('documents').add(data); + + logger.info('Create document!', { structuredData: true }); + + response.send({ message: 'Document created!' }); +}); + +export const onDocumentCreate = onDocumentCreated('documents/{documentId}', async event => { + const documentId = event.params.documentId; + + await db.collection('documents').doc(documentId).update({ + processed: true, + processedAt: new Date(), + }); +}); + +export const onDocumentCreateWithAuthContext = onDocumentCreatedWithAuthContext('documents/{documentId}', async () => { + // noop +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts new file mode 100644 index 000000000000..c3b4a642375a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.firebaseIntegration()], + defaultIntegrations: false, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json index 0a23fbbeef92..41eb0ce085d4 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -3,34 +3,26 @@ "version": "0.0.1", "private": true, "scripts": { - "build": "tsc", - "dev": "tsc --build --watch", + "build": "pnpm run -r build", + "dev": "pnpm run -r dev", "proxy": "node start-event-proxy.mjs", - "emulate": "firebase emulators:start &", - "start": "node ./dist/app.js", + "emulate": "firebase emulators:start --project demo-functions", + "start": "pnpm run -r start", "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", + "clean": "npx rimraf node_modules **/node_modules pnpm-lock.yaml **/dist *-debug.log test-results", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm firebase emulators:exec 'pnpm test'" + "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'" }, "dependencies": { - "@firebase/app": "^0.13.1", - "@sentry/node": "latest || *", - "@sentry/core": "latest || *", - "@sentry/opentelemetry": "latest || *", - "@types/node": "^18.19.1", + "@types/node": "^22.13.14", "dotenv": "^16.4.5", - "express": "^4.18.2", - "firebase": "^12.0.0", - "firebase-admin": "^12.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "4.9.5" + "typescript": "5.9.3" }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/express": "^4.17.13", - "firebase-tools": "^12.0.0" + "firebase-tools": "^14.20.0" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml new file mode 100644 index 000000000000..8a5eb172e019 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'functions' + - 'firestore-app' diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts new file mode 100644 index 000000000000..2600b8bc1ec5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should only call the function once without any extra calls', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + await fetch(`http://localhost:5001/demo-functions/default/helloWorld`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts).toEqual( + expect.objectContaining({ + trace: expect.objectContaining({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'helloWorld', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + }), + ); +}); + +test('should send failed transaction when the function fails', async () => { + const errorEventPromise = waitForError('node-firebase', () => true); + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return !!span.transaction; + }); + + await fetch(`http://localhost:5001/demo-functions/default/unhandeledError`); + + const transactionEvent = await serverTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace?.trace_id).toEqual(errorEvent.contexts?.trace?.trace_id); + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'There is an error!', + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }, + ], + }, + }); +}); + +test('should create a document and trigger onDocumentCreated and another with authContext', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + const serverTransactionOnDocumentCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreate' + ); + }); + + const serverTransactionOnDocumentWithAuthContextCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreateWithAuthContext' + ); + }); + + await fetch(`http://localhost:5001/demo-functions/default/onCallSomething`); + + const transactionEvent = await serverTransactionPromise; + const transactionEventOnDocumentCreate = await serverTransactionOnDocumentCreatePromise; + const transactionEventOnDocumentWithAuthContextCreate = await serverTransactionOnDocumentWithAuthContextCreatePromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onCallSomething', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEvent.spans).toHaveLength(3); + expect(transactionEventOnDocumentCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreate', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentCreate.spans).toHaveLength(2); + expect(transactionEventOnDocumentWithAuthContextCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreateWithAuthContext', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentWithAuthContextCreate.spans).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json deleted file mode 100644 index 26c30d4eddf2..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json index 8cb64e989ed9..881847032511 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -3,8 +3,6 @@ "types": ["node"], "esModuleInterop": true, "lib": ["es2018"], - "strict": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts"] + "strict": true + } } diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index 3dea78b20080..afe9486eeebf 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -11,10 +11,11 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "~5.0.0" + "typescript": "~4.9.5" }, "scripts": { "build": "react-scripts build", + "dev": "react-scripts start", "start": "serve -s build", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 2ad9490ccd57..86de5f20378d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -11,6 +11,7 @@ import { } from 'react-router-dom'; import Index from './pages/Index'; import User from './pages/User'; +import Group from './pages/Group'; const replay = Sentry.replayIntegration(); @@ -52,6 +53,31 @@ const router = sentryCreateHashRouter([ path: '/user/:id', element: , }, + { + path: '/group/:group/:user?', + element: , + }, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, ]); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx new file mode 100644 index 000000000000..9dd9ac110898 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const Group = () => { + return

Group page

; +}; + +export default Group; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx index 1000dd53df27..20a2ab60fa21 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -15,6 +15,24 @@ const Index = () => { navigate + + Post 1 + + + Edit Post 1 + + + Post 1 featured + + + Post 1 related + + + Group 1 + + + Group 1 user 5 + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx new file mode 100644 index 000000000000..9b844b17ff68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Outlet } from 'react-router-dom'; + +const Post = () => { + return ( + <> +

Post V2 page

+ + + ); +}; + +export default Post; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx new file mode 100644 index 000000000000..0446fa0ec6ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostFeatured = () => { + return

Post featured page

; +}; + +export default PostFeatured; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx new file mode 100644 index 000000000000..ad3efaa9216a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostIndex = () => { + return

Post index page

; +}; + +export default PostIndex; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx new file mode 100644 index 000000000000..ff8d05f6f6f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostRelated = () => { + return

Post related page

; +}; + +export default PostRelated; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index 920506838080..36e6d0c18ee2 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -14,9 +14,9 @@ test('Captures a pageload transaction', async ({ page }) => { deviceMemory: expect.any(String), effectiveConnectionType: expect.any(String), hardwareConcurrency: expect.any(String), - 'lcp.element': 'body > div#root > input#exception-button[type="button"]', - 'lcp.id': 'exception-button', - 'lcp.size': 1650, + 'lcp.element': expect.any(String), + 'lcp.id': expect.any(String), + 'lcp.size': expect.any(Number), 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.react.reactrouter_v6', @@ -150,3 +150,255 @@ test('Captures a navigation transaction', async ({ page }) => { expect(transactionEvent.spans).toEqual([]); }); + +test('Captures a parameterized path pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/featured'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v1/post/1/edit'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/related'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-featured'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-edit'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-related'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1/5'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1-user-5'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index bfcc527ded1b..089b27ab974a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -89,6 +89,14 @@ const ProjectsRoutes = () => ( ); const router = sentryCreateBrowserRouter([ + { + path: '/post/:post', + element:
Post
, + children: [ + { index: true, element:
Post Index
}, + { path: '/post/:post/related', element:
Related Posts
}, + ], + }, { children: [ { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 59d43c14ae95..3901b0938ca5 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -377,3 +377,124 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', expect(backNavigationEvent.transaction).toBe('/'); expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); }); + +test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up the page to simulate document.hidden before navigation + await page.addInitScript(() => { + // Wait a bit for Sentry to initialize and start the pageload span + setTimeout(() => { + // Override document.hidden to simulate tab switching + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return true; + }, + }); + + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 100); // Small delay to ensure the span has started + }); + + // Navigate to the lazy route URL + await page.goto('/lazy/inner/1/2/3'); + + const event = await transactionPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('pageload'); + + // Check if the span was indeed cancelled (should have idle_span_finish_reason attribute) + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + if (idleSpanFinishReason) { + // If the span was cancelled due to visibility change, verify it still got the right name + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); + +test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + // First go to home page + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up a listener to simulate document.hidden after clicking the navigation link + await page.evaluate(() => { + // Override document.hidden to simulate tab switching + let hiddenValue = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return hiddenValue; + }, + }); + + // Listen for clicks on the navigation link and simulate document.hidden shortly after + document.addEventListener( + 'click', + () => { + setTimeout(() => { + hiddenValue = true; + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 50); // Small delay to ensure the navigation span has started + }, + { once: true }, + ); + }); + + // Click the navigation link to navigate to the lazy route + const navigationLink = page.locator('id=navigation'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await navigationPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('navigation'); + + // Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason) + const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason']; + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + + // Verify that the span was cancelled due to document.hidden + if (cancellationReason) { + expect(cancellationReason).toBe('document.hidden'); + } + + if (idleSpanFinishReason) { + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); diff --git a/dev-packages/node-core-integration-tests/suites/winston/test.ts b/dev-packages/node-core-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5f2ab2023405..b4fd1c3b4125 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -27,6 +27,8 @@ "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", + "@langchain/anthropic": "^0.3.10", + "@langchain/core": "^0.3.28", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index cf396e319c51..2ee47a17dd20 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -18,7 +18,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -35,7 +35,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -52,7 +52,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -83,7 +83,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -100,7 +100,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -117,7 +117,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -135,7 +135,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -152,7 +152,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -169,7 +169,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -186,7 +186,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -203,7 +203,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -220,7 +220,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -236,7 +236,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -253,7 +253,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -283,7 +283,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -300,7 +300,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -342,7 +342,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -360,7 +360,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -392,7 +392,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -410,7 +410,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -440,7 +440,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -457,7 +457,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -474,7 +474,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs index 2e968444a74f..e55f11f9c00c 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -17,7 +17,18 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + // This child should be captured as we marked the parent logger to be tracked + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); + + // This child should be ignored + const child2 = logger.child({ module: 'authentication.v2' }); + Sentry.pinoIntegration.untrackLogger(child2); + child2.error(new Error('oh no v2')); + + // This should also be ignored as the parent is ignored + const child3 = child2.child({ module: 'authentication.v3' }); + child3.error(new Error('oh no v3')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index beb080ac3c42..55966552a07f 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -17,7 +17,8 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 1982c8d686fc..a2ec57b57e56 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -45,7 +45,6 @@ conditionalTest({ min: 20 })('Pino integration', () => { function: '?', in_app: true, module: 'scenario', - context_line: " logger.error(new Error('oh no'));", }), ]), }, @@ -63,18 +62,18 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -82,14 +81,16 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, @@ -139,18 +140,18 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -158,14 +159,15 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, @@ -189,18 +191,19 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + msg: { value: 'hello world', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -208,14 +211,16 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - err: { value: '{}', type: 'string' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, ], }, diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs new file mode 100644 index 000000000000..21821cdc5aae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -0,0 +1,71 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.baseURL = config.baseURL; + + // Create messages object with create method + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'msg-truncation-test', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response to truncated messages', + }, + ], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: largeContent1 }, + { role: 'assistant', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs index ac5eb6019010..4e0fa74fdd0d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs @@ -32,6 +32,62 @@ function createMockStreamEvents(model = 'claude-3-haiku-20240307') { return generator(); } +// Mimics Anthropic SDK's MessageStream class +class MockMessageStream { + constructor(model) { + this._model = model; + this._eventHandlers = {}; + } + + on(event, handler) { + if (!this._eventHandlers[event]) { + this._eventHandlers[event] = []; + } + this._eventHandlers[event].push(handler); + + // Start processing events asynchronously (don't await) + if (event === 'streamEvent' && !this._processing) { + this._processing = true; + this._processEvents(); + } + + return this; + } + + async _processEvents() { + try { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + if (this._eventHandlers['streamEvent']) { + for (const handler of this._eventHandlers['streamEvent']) { + handler(event); + } + } + } + + // Emit 'message' event when done + if (this._eventHandlers['message']) { + for (const handler of this._eventHandlers['message']) { + handler(); + } + } + } catch (error) { + if (this._eventHandlers['error']) { + for (const handler of this._eventHandlers['error']) { + handler(error); + } + } + } + } + + async *[Symbol.asyncIterator]() { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + yield event; + } + } +} + class MockAnthropic { constructor(config) { this.apiKey = config.apiKey; @@ -68,9 +124,9 @@ class MockAnthropic { }; } - async _messagesStream(params) { - await new Promise(resolve => setTimeout(resolve, 5)); - return createMockStreamEvents(params?.model); + // This should return synchronously (like the real Anthropic SDK) + _messagesStream(params) { + return new MockMessageStream(params?.model); } } @@ -90,13 +146,27 @@ async function run() { } // 2) Streaming via messages.stream API - const stream2 = await client.messages.stream({ + const stream2 = client.messages.stream({ model: 'claude-3-haiku-20240307', messages: [{ role: 'user', content: 'Stream this too' }], }); for await (const _ of stream2) { void _; } + + // 3) Streaming via messages.stream API with redundant stream: true param + const stream3 = client.messages.stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream with param' }], + stream: true, // This param is redundant but should not break synchronous behavior + }); + // Verify it has .on() method immediately (not a Promise) + if (typeof stream3.on !== 'function') { + throw new Error('BUG: messages.stream() with stream: true did not return MessageStream synchronously!'); + } + for await (const _ of stream3) { + void _; + } }); } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index c05db16fc251..2c92c6f8d233 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -308,6 +308,23 @@ describe('Anthropic integration', () => { 'gen_ai.usage.total_tokens': 25, }), }), + // messages.stream with redundant stream: true param + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.system': 'anthropic', + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + 'gen_ai.response.streaming': true, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_stream_1', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + }), ]), }; @@ -331,6 +348,14 @@ describe('Anthropic integration', () => { 'gen_ai.response.text': 'Hello from stream!', }), }), + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'Hello from stream!', + }), + }), ]), }; @@ -497,4 +522,40 @@ describe('Anthropic integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_ERROR_SPANS }).start().completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + // Messages should be present (truncation happened) and should be a JSON array + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..bb24b6835db2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + response: { + text: () => 'Response to truncated messages', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 15, + totalTokenCount: 25, + }, + candidates: [ + { + content: { + parts: [{ text: 'Response to truncated messages' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentGoogleGenAIClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { role: 'user', parts: [{ text: largeContent1 }] }, + { role: 'model', parts: [{ text: largeContent2 }] }, + { role: 'user', parts: [{ text: largeContent3 }] }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 92d669c7e10f..921f94e78765 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -486,4 +486,42 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + // Messages should be present (truncation happened) and should be a JSON array with parts + 'gen_ai.request.messages': expect.stringMatching( + /^\[\{"role":"user","parts":\[\{"text":"C+"\}\]\}\]$/, + ), + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts new file mode 100644 index 000000000000..0843830321c4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.nativeNodeFetchIntegration({ + requestHook: (span, req) => { + span.setAttribute('sentry.request.hook', req.path); + }, + responseHook: (span, { response, request }) => { + span.setAttribute('sentry.response.hook.path', request.path); + span.setAttribute('sentry.response.hook.status_code', response.statusCode); + }, + }), + ], +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + await fetch(`${process.env.SERVER_URL}/api/v1`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts new file mode 100644 index 000000000000..8d0a35a43d05 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('adds requestHook and responseHook attributes to spans of outgoing fetch requests', async () => { + expect.assertions(3); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .get( + '/api/v1', + () => { + // Just ensure we're called + expect(true).toBe(true); + }, + 404, + ) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + transaction: 'test_transaction', + spans: [ + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v0/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v0', + 'sentry.response.hook.path': '/api/v0', + 'sentry.response.hook.status_code': 200, + }), + }), + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v1/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'not_found', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v1', + 'sentry.response.hook.path': '/api/v1', + 'sentry.response.hook.status_code': 404, + 'http.response.status_code': 404, + }), + }), + ], + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs new file mode 100644 index 000000000000..85b2a963d977 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs new file mode 100644 index 000000000000..524d19f4b995 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs new file mode 100644 index 000000000000..256ee4568884 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs @@ -0,0 +1,90 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + // Simulate tool call response + res.json({ + id: 'msg_tool_test_123', + type: 'message', + role: 'assistant', + model: model, + content: [ + { + type: 'text', + text: 'Let me check the weather for you.', + }, + { + type: 'tool_use', + id: 'toolu_01A09q90qw90lq917835lq9', + name: 'get_weather', + input: { location: 'San Francisco, CA' }, + }, + { + type: 'text', + text: 'The weather looks great!', + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 30, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 150, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model.invoke('What is the weather in San Francisco?', { + tools: [ + { + name: 'get_weather', + description: 'Get the current weather in a given location', + input_schema: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA', + }, + }, + required: ['location'], + }, + }, + ], + }); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs new file mode 100644 index 000000000000..2c60e55ff77e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs @@ -0,0 +1,110 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + if (model === 'error-model') { + res + .status(400) + .set('request-id', 'mock-request-123') + .json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'Model not found', + }, + }); + return; + } + + // Simulate basic response + res.json({ + id: 'msg_test123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Mock response from Anthropic!', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Test 1: Basic chat model invocation + const model1 = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model1.invoke('Tell me a joke'); + + // Test 2: Chat with different model + const model2 = new ChatAnthropic({ + model: 'claude-3-opus-20240229', + temperature: 0.9, + topP: 0.95, + maxTokens: 200, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model2.invoke([ + { role: 'system', content: 'You are a helpful assistant' }, + { role: 'user', content: 'What is the capital of France?' }, + ]); + + // Test 3: Error handling + const errorModel = new ChatAnthropic({ + model: 'error-model', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + try { + await errorModel.invoke('This will fail'); + } catch { + // Expected error + } + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..e3738b61b7a7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,197 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('LangChain integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with claude-3-5-sonnet + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with claude-3-opus + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + const EXPECTED_TRANSACTION_TOOL_CALLS = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 150, + 'gen_ai.usage.input_tokens': 20, + 'gen_ai.usage.output_tokens': 30, + 'gen_ai.usage.total_tokens': 50, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': 'tool_use', + 'gen_ai.response.tool_calls': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain spans with tool calls', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOL_CALLS }).start().completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..5623d3763657 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'chatcmpl-truncation-test', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Response to truncated messages', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + }, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: largeContent1 }, + { role: 'user', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index c0c0b79e95f7..8c788834f126 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -187,7 +187,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', - 'gen_ai.request.messages': '"Translate this to French: Hello"', + 'gen_ai.request.messages': 'Translate this to French: Hello', 'gen_ai.response.text': 'Response to: Translate this to French: Hello', 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -261,7 +261,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, - 'gen_ai.request.messages': '"Test streaming responses API"', + 'gen_ai.request.messages': 'Test streaming responses API', 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', 'gen_ai.response.finish_reasons': '["in_progress","completed"]', 'gen_ai.response.id': 'resp_stream_456', @@ -397,4 +397,40 @@ describe('OpenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + // Messages should be present (truncation happened) and should be a JSON array of a single index + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/package.json b/package.json index 0c3d47a3c7b3..1fd6eb062564 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "eslint": "7.32.0", "jsdom": "^21.1.2", "lerna": "7.1.1", - "madge": "7.0.0", + "madge": "8.0.0", "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 15158bdbb7bc..69ca79e04a17 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -31,6 +31,7 @@ export { contextLinesIntegration, continueTrace, createGetModuleFromFilename, + createLangChainCallbackHandler, createTransport, cron, dataloaderIntegration, @@ -93,6 +94,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, parameterize, pinoIntegration, postgresIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 5ff30f069486..da0393d9b0e9 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5ec1568229e4..33af15790191 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -62,6 +62,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -76,6 +77,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 6f731cb8d980..a6aa7ffc8d9a 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -96,6 +96,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, growthbookIntegration, logger, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a6c5c2e17d3..f3b29009b9ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,6 +144,9 @@ export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export { instrumentGoogleGenAIClient } from './utils/google-genai'; export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; export type { GoogleGenAIResponse } from './utils/google-genai/types'; +export { createLangChainCallbackHandler } from './utils/langchain'; +export { LANGCHAIN_INTEGRATION_NAME } from './utils/langchain/constants'; +export type { LangChainOptions, LangChainIntegration } from './utils/langchain/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { AnthropicAiClient, diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 1caa7d2f212f..4781b253b161 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -217,7 +217,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const message = messageParts.join(' '); // Build attributes - attributes['sentry.origin'] = 'auto.logging.consola'; + attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { attributes['consola.tag'] = tag; diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index bf49c745e788..ccf14e3ebf48 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -16,7 +16,7 @@ interface CaptureConsoleOptions { const INTEGRATION_NAME = 'ConsoleLogs'; const DEFAULT_ATTRIBUTES = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.console.logging', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.log.console', }; const _consoleLoggingIntegration = ((options: Partial = {}) => { diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index d55851927cb6..84efb21c1822 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -80,6 +80,11 @@ export const GEN_AI_RESPONSE_MODEL_ATTRIBUTE = 'gen_ai.response.model'; */ export const GEN_AI_RESPONSE_ID_ATTRIBUTE = 'gen_ai.response.id'; +/** + * The reason why the model stopped generating tokens + */ +export const GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE = 'gen_ai.response.stop_reason'; + /** * The number of tokens used in the prompt */ @@ -129,6 +134,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache creation input tokens used + */ +export const GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_creation_input_tokens'; + +/** + * The number of cache read input tokens used + */ +export const GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_read_input_tokens'; + /** * The number of cache write input tokens used */ diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts new file mode 100644 index 000000000000..64d186f927b8 --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,296 @@ +/** + * Default maximum size in bytes for GenAI messages. + * Messages exceeding this limit will be truncated. + */ +export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; + +/** + * Message format used by OpenAI and Anthropic APIs. + */ +type ContentMessage = { + [key: string]: unknown; + content: string; +}; + +/** + * Message format used by Google GenAI API. + * Parts can be strings or objects with a text property. + */ +type PartsMessage = { + [key: string]: unknown; + parts: Array; +}; + +/** + * A part in a Google GenAI message that contains text. + */ +type TextPart = string | { text: string }; + +/** + * Calculate the UTF-8 byte length of a string. + */ +const utf8Bytes = (text: string): number => { + return new TextEncoder().encode(text).length; +}; + +/** + * Calculate the UTF-8 byte length of a value's JSON representation. + */ +const jsonBytes = (value: unknown): number => { + return utf8Bytes(JSON.stringify(value)); +}; + +/** + * Truncate a string to fit within maxBytes when encoded as UTF-8. + * Uses binary search for efficiency with multi-byte characters. + * + * @param text - The string to truncate + * @param maxBytes - Maximum byte length (UTF-8 encoded) + * @returns Truncated string that fits within maxBytes + */ +function truncateTextByBytes(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) { + return text; + } + + let low = 0; + let high = text.length; + let bestFit = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = text.slice(0, mid); + const byteSize = utf8Bytes(candidate); + + if (byteSize <= maxBytes) { + bestFit = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return bestFit; +} + +/** + * Extract text content from a Google GenAI message part. + * Parts are either plain strings or objects with a text property. + * + * @returns The text content + */ +function getPartText(part: TextPart): string { + if (typeof part === 'string') { + return part; + } + return part.text; +} + +/** + * Create a new part with updated text content while preserving the original structure. + * + * @param part - Original part (string or object) + * @param text - New text content + * @returns New part with updated text + */ +function withPartText(part: TextPart, text: string): TextPart { + if (typeof part === 'string') { + return text; + } + return { ...part, text }; +} + +/** + * Check if a message has the OpenAI/Anthropic content format. + */ +function isContentMessage(message: unknown): message is ContentMessage { + return ( + message !== null && + typeof message === 'object' && + 'content' in message && + typeof (message as ContentMessage).content === 'string' + ); +} + +/** + * Check if a message has the Google GenAI parts format. + */ +function isPartsMessage(message: unknown): message is PartsMessage { + return ( + message !== null && + typeof message === 'object' && + 'parts' in message && + Array.isArray((message as PartsMessage).parts) && + (message as PartsMessage).parts.length > 0 + ); +} + +/** + * Truncate a message with `content: string` format (OpenAI/Anthropic). + * + * @param message - Message with content property + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncateContentMessage(message: ContentMessage, maxBytes: number): unknown[] { + // Calculate overhead (message structure without content) + const emptyMessage = { ...message, content: '' }; + const overhead = jsonBytes(emptyMessage); + const availableForContent = maxBytes - overhead; + + if (availableForContent <= 0) { + return []; + } + + const truncatedContent = truncateTextByBytes(message.content, availableForContent); + return [{ ...message, content: truncatedContent }]; +} + +/** + * Truncate a message with `parts: [...]` format (Google GenAI). + * Keeps as many complete parts as possible, only truncating the first part if needed. + * + * @param message - Message with parts array + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { + const { parts } = message; + + // Calculate overhead by creating empty text parts + const emptyParts = parts.map(part => withPartText(part, '')); + const overhead = jsonBytes({ ...message, parts: emptyParts }); + let remainingBytes = maxBytes - overhead; + + if (remainingBytes <= 0) { + return []; + } + + // Include parts until we run out of space + const includedParts: TextPart[] = []; + + for (const part of parts) { + const text = getPartText(part); + const textSize = utf8Bytes(text); + + if (textSize <= remainingBytes) { + // Part fits: include it as-is + includedParts.push(part); + remainingBytes -= textSize; + } else if (includedParts.length === 0) { + // First part doesn't fit: truncate it + const truncated = truncateTextByBytes(text, remainingBytes); + if (truncated) { + includedParts.push(withPartText(part, truncated)); + } + break; + } else { + // Subsequent part doesn't fit: stop here + break; + } + } + + return includedParts.length > 0 ? [{ ...message, parts: includedParts }] : []; +} + +/** + * Truncate a single message to fit within maxBytes. + * + * Supports two message formats: + * - OpenAI/Anthropic: `{ ..., content: string }` + * - Google GenAI: `{ ..., parts: Array }` + * + * @param message - The message to truncate + * @param maxBytes - Maximum byte limit for the message + * @returns Array containing the truncated message, or empty array if truncation fails + */ +function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { + if (!message || typeof message !== 'object') { + return []; + } + + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } + + if (isPartsMessage(message)) { + return truncatePartsMessage(message, maxBytes); + } + + // Unknown message format: cannot truncate safely + return []; +} + +/** + * Truncate an array of messages to fit within a byte limit. + * + * Strategy: + * - Keeps the newest messages (from the end of the array) + * - Uses O(n) algorithm: precompute sizes once, then find largest suffix under budget + * - If no complete messages fit, attempts to truncate the newest single message + * + * @param messages - Array of messages to truncate + * @param maxBytes - Maximum total byte limit for all messages + * @returns Truncated array of messages + * + * @example + * ```ts + * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 + * const truncated = truncateMessagesByBytes(messages, 10000); + * // Returns [msg3, msg4] if they fit, or [msg4] if only it fits, etc. + * ``` + */ +export function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + // Early return for empty or invalid input + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + // Fast path: if all messages fit, return as-is + const totalBytes = jsonBytes(messages); + if (totalBytes <= maxBytes) { + return messages; + } + + // Precompute each message's JSON size once for efficiency + const messageSizes = messages.map(jsonBytes); + + // Find the largest suffix (newest messages) that fits within the budget + let bytesUsed = 0; + let startIndex = messages.length; // Index where the kept suffix starts + + for (let i = messages.length - 1; i >= 0; i--) { + const messageSize = messageSizes[i]; + + if (messageSize && bytesUsed + messageSize > maxBytes) { + // Adding this message would exceed the budget + break; + } + + if (messageSize) { + bytesUsed += messageSize; + } + startIndex = i; + } + + // If no complete messages fit, try truncating just the newest message + if (startIndex === messages.length) { + const newestMessage = messages[messages.length - 1]; + return truncateSingleMessage(newestMessage, maxBytes); + } + + // Return the suffix that fits + return messages.slice(startIndex); +} + +/** + * Truncate GenAI messages using the default byte limit. + * + * Convenience wrapper around `truncateMessagesByBytes` with the default limit. + * + * @param messages - Array of messages to truncate + * @returns Truncated array of messages + */ +export function truncateGenAiMessages(messages: unknown[]): unknown[] { + return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index ecb46d5f0d0d..00e147a16e5f 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -7,6 +7,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from './gen-ai-attributes'; +import { truncateGenAiMessages } from './messageTruncation'; /** * Maps AI method paths to Sentry operation name */ @@ -84,3 +85,23 @@ export function setTokenUsageAttributes( }); } } + +/** + * Get the truncated JSON string for a string or array of strings. + * + * @param value - The string or array of strings to truncate + * @returns The truncated JSON string + */ +export function getTruncatedJsonString(value: T | T[]): string { + if (typeof value === 'string') { + // Some values are already JSON strings, so we don't need to duplicate the JSON parsing + return value; + } + if (Array.isArray(value)) { + // truncateGenAiMessages returns an array of strings, so we need to stringify it + const truncatedMessages = truncateGenAiMessages(value); + return JSON.stringify(truncatedMessages); + } + // value is an object, so we need to stringify it + return JSON.stringify(value); +} diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8e77dd76b34e..669d8a61b068 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,7 +23,13 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { + buildMethodPath, + getFinalOperationName, + getSpanOperation, + getTruncatedJsonString, + setTokenUsageAttributes, +} from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -33,7 +39,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { shouldInstrument } from './utils'; +import { handleResponseError, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -77,33 +83,19 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } + if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); } } -/** - * Capture error information from the response - * @see https://docs.anthropic.com/en/api/errors#error-shapes - */ -function handleResponseError(span: Span, response: AnthropicAiResponse): void { - if (response.error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); - - captureException(response.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); - } -} - /** * Add content attributes when recordOutputs is enabled */ @@ -213,8 +205,8 @@ function handleStreamingError(error: unknown, span: Span, methodPath: string): n * Handle streaming cases with common logic */ function handleStreamingRequest( - originalMethod: (...args: T) => Promise, - target: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, + target: (...args: T) => R | Promise, context: unknown, args: T, requestAttributes: Record, @@ -223,7 +215,8 @@ function handleStreamingRequest( params: Record | undefined, options: AnthropicAiOptions, isStreamRequested: boolean, -): Promise { + isStreamingMethod: boolean, +): R | Promise { const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const spanConfig = { name: `${operationName} ${model} stream-response`, @@ -231,7 +224,8 @@ function handleStreamingRequest( attributes: requestAttributes as Record, }; - if (isStreamRequested) { + // messages.stream() always returns a sync MessageStream, even with stream: true param + if (isStreamRequested && !isStreamingMethod) { return startSpanManual(spanConfig, async span => { try { if (options.recordInputs && params) { @@ -268,13 +262,13 @@ function handleStreamingRequest( * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation */ function instrumentMethod( - originalMethod: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, methodPath: AnthropicAiInstrumentedMethod, context: unknown, options: AnthropicAiOptions, -): (...args: T) => Promise { +): (...args: T) => R | Promise { return new Proxy(originalMethod, { - apply(target, thisArg, args: T): Promise { + apply(target, thisArg, args: T): R | Promise { const requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); @@ -295,6 +289,7 @@ function instrumentMethod( params, options, isStreamRequested, + isStreamingMethod, ); } @@ -328,7 +323,7 @@ function instrumentMethod( }, ); }, - }) as (...args: T) => Promise; + }) as (...args: T) => R | Promise; } /** @@ -341,7 +336,7 @@ function createDeepProxy(target: T, currentPath = '', options: const methodPath = buildMethodPath(currentPath, String(prop)); if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + return instrumentMethod(value as (...args: unknown[]) => unknown | Promise, methodPath, obj, options); } if (typeof value === 'function') { diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts index 299d20170d6c..bce96aa68bcc 100644 --- a/packages/core/src/utils/anthropic-ai/utils.ts +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -1,5 +1,8 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod } from './types'; +import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; /** * Check if a method path should be instrumented @@ -7,3 +10,20 @@ import type { AnthropicAiInstrumentedMethod } from './types'; export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } + +/** + * Capture error information from the response + * @see https://docs.anthropic.com/en/api/errors#error-shapes + */ +export function handleResponseError(span: Span, response: AnthropicAiResponse): void { + if (response.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); + + captureException(response.error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.anthropic_error', + }, + }); + } +} diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 20e6e2a53606..9639b1255d29 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,7 +22,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -136,17 +136,24 @@ function extractRequestAttributes( function addPrivateRequestAttributes(span: Span, params: Record): void { // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] if ('contents' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.contents) }); + const contents = params.contents; + // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] + const truncatedContents = getTruncatedJsonString(contents); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedContents }); } // For chat.sendMessage: message can be string or Part[] if ('message' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + const message = params.message; + const truncatedMessage = getTruncatedJsonString(message); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessage }); } // For chats.create: history contains the conversation history if ('history' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + const history = params.history; + const truncatedHistory = getTruncatedJsonString(history); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedHistory }); } } diff --git a/packages/core/src/utils/langchain/constants.ts b/packages/core/src/utils/langchain/constants.ts new file mode 100644 index 000000000000..ead9bed623ad --- /dev/null +++ b/packages/core/src/utils/langchain/constants.ts @@ -0,0 +1,11 @@ +export const LANGCHAIN_INTEGRATION_NAME = 'LangChain'; +export const LANGCHAIN_ORIGIN = 'auto.ai.langchain'; + +export const ROLE_MAP: Record = { + human: 'user', + ai: 'assistant', + assistant: 'assistant', + system: 'system', + function: 'function', + tool: 'tool', +}; diff --git a/packages/core/src/utils/langchain/index.ts b/packages/core/src/utils/langchain/index.ts new file mode 100644 index 000000000000..1930be794be5 --- /dev/null +++ b/packages/core/src/utils/langchain/index.ts @@ -0,0 +1,321 @@ +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpanManual } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE } from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN } from './constants'; +import type { + LangChainCallbackHandler, + LangChainLLMResult, + LangChainMessage, + LangChainOptions, + LangChainSerialized, +} from './types'; +import { + extractChatModelRequestAttributes, + extractLLMRequestAttributes, + extractLlmResponseAttributes, + getInvocationParams, +} from './utils'; + +/** + * Creates a Sentry callback handler for LangChain + * Returns a plain object that LangChain will call via duck-typing + * + * This is a stateful handler that tracks spans across multiple LangChain executions. + */ +export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { + const recordInputs = options.recordInputs ?? false; + const recordOutputs = options.recordOutputs ?? false; + + // Internal state - single instance tracks all spans + const spanMap = new Map(); + + /** + * Exit a span and clean up + */ + const exitSpan = (runId: string): void => { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.end(); + spanMap.delete(runId); + } + }; + + /** + * Handler for LLM Start + * This handler will be called by LangChain's callback handler when an LLM event is detected. + */ + const handler: LangChainCallbackHandler = { + // Required LangChain BaseCallbackHandler properties + lc_serializable: false, + lc_namespace: ['langchain_core', 'callbacks', 'sentry'], + lc_secrets: undefined, + lc_attributes: undefined, + lc_aliases: undefined, + lc_serializable_keys: undefined, + lc_id: ['langchain_core', 'callbacks', 'sentry'], + lc_kwargs: {}, + name: 'SentryCallbackHandler', + + // BaseCallbackHandlerInput boolean flags + ignoreLLM: false, + ignoreChain: false, + ignoreAgent: false, + ignoreRetriever: false, + ignoreCustomEvent: false, + raiseError: false, + awaitHandlers: true, + + handleLLMStart( + llm: unknown, + prompts: string[], + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractLLMRequestAttributes( + llm as LangChainSerialized, + prompts, + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.pipeline', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.pipeline', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chat Model Start Handler + handleChatModelStart( + llm: unknown, + messages: unknown, + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractChatModelRequestAttributes( + llm as LangChainSerialized, + messages as LangChainMessage[][], + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.chat', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // LLM End Handler - note: handleLLMEnd with capital LLM (used by both LLMs and chat models!) + handleLLMEnd( + output: unknown, + runId: string, + _parentRunId?: string, + _tags?: string[], + _extraParams?: Record, + ) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + const attributes = extractLlmResponseAttributes(output as LangChainLLMResult, recordOutputs); + if (attributes) { + span.setAttributes(attributes); + } + exitSpan(runId); + } + }, + + // LLM Error Handler - note: handleLLMError with capital LLM + handleLLMError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'llm_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.llm_error_handler`, + }, + }); + }, + + // Chain Start Handler + handleChainStart(chain: { name?: string }, inputs: Record, runId: string, _parentRunId?: string) { + const chainName = chain.name || 'unknown_chain'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', + 'langchain.chain.name': chainName, + }; + + // Add inputs if recordInputs is enabled + if (recordInputs) { + attributes['langchain.chain.inputs'] = JSON.stringify(inputs); + } + + startSpanManual( + { + name: `chain ${chainName}`, + op: 'gen_ai.invoke_agent', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chain End Handler + handleChainEnd(outputs: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add outputs if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'langchain.chain.outputs': JSON.stringify(outputs), + }); + } + exitSpan(runId); + } + }, + + // Chain Error Handler + handleChainError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'chain_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.chain_error_handler`, + }, + }); + }, + + // Tool Start Handler + handleToolStart(tool: { name?: string }, input: string, runId: string, _parentRunId?: string) { + const toolName = tool.name || 'unknown_tool'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + 'gen_ai.tool.name': toolName, + }; + + // Add input if recordInputs is enabled + if (recordInputs) { + attributes['gen_ai.tool.input'] = input; + } + + startSpanManual( + { + name: `execute_tool ${toolName}`, + op: 'gen_ai.execute_tool', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Tool End Handler + handleToolEnd(output: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add output if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'gen_ai.tool.output': JSON.stringify(output), + }); + } + exitSpan(runId); + } + }, + + // Tool Error Handler + handleToolError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'tool_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.tool_error_handler`, + }, + }); + }, + + // LangChain BaseCallbackHandler required methods + copy() { + return handler; + }, + + toJSON() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + + toJSONNotImplemented() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + }; + + return handler; +} diff --git a/packages/core/src/utils/langchain/types.ts b/packages/core/src/utils/langchain/types.ts new file mode 100644 index 000000000000..e08542eefd60 --- /dev/null +++ b/packages/core/src/utils/langchain/types.ts @@ -0,0 +1,208 @@ +/** + * Options for LangChain integration + */ +export interface LangChainOptions { + /** + * Whether to record input messages/prompts + * @default false (respects sendDefaultPii option) + */ + recordInputs?: boolean; + + /** + * Whether to record output text and responses + * @default false (respects sendDefaultPii option) + */ + recordOutputs?: boolean; +} + +/** + * LangChain Serialized type (compatible with @langchain/core) + * Uses general types to be compatible with LangChain's Serialized interface. + * This is a flexible interface that accepts any serialized LangChain object. + */ +export interface LangChainSerialized { + [key: string]: unknown; + lc?: number; + type?: string; + id?: string[]; + name?: string; + graph?: Record; + kwargs?: Record; +} + +/** + * LangChain message structure + * Supports both regular messages and LangChain serialized format + */ +export interface LangChainMessage { + [key: string]: unknown; + type?: string; + content?: string; + message?: { + content?: unknown[]; + type?: string; + }; + role?: string; + additional_kwargs?: Record; + // LangChain serialized format + lc?: number; + id?: string[]; + kwargs?: { + [key: string]: unknown; + content?: string; + additional_kwargs?: Record; + response_metadata?: Record; + }; +} + +/** + * LangChain LLM result structure + */ +export interface LangChainLLMResult { + [key: string]: unknown; + generations: Array< + Array<{ + text?: string; + message?: LangChainMessage; + generation_info?: { + [key: string]: unknown; + + finish_reason?: string; + logprobs?: unknown; + }; + }> + >; + llmOutput?: { + [key: string]: unknown; + tokenUsage?: { + completionTokens?: number; + promptTokens?: number; + totalTokens?: number; + }; + model_name?: string; + }; +} + +/** + * Integration interface for type safety + */ +export interface LangChainIntegration { + name: string; + options: LangChainOptions; +} + +/** + * LangChain callback handler interface + * Compatible with both BaseCallbackHandlerMethodsClass and BaseCallbackHandler from @langchain/core + * Uses general types and index signature for maximum compatibility across LangChain versions + */ +export interface LangChainCallbackHandler { + // Allow any additional properties for full compatibility + [key: string]: unknown; + + // LangChain BaseCallbackHandler class properties (matching the class interface exactly) + lc_serializable: boolean; + lc_namespace: ['langchain_core', 'callbacks', string]; + lc_secrets: { [key: string]: string } | undefined; + lc_attributes: { [key: string]: string } | undefined; + lc_aliases: { [key: string]: string } | undefined; + lc_serializable_keys: string[] | undefined; + lc_id: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lc_kwargs: { [key: string]: any }; + name: string; + + // BaseCallbackHandlerInput properties (required boolean flags) + ignoreLLM: boolean; + ignoreChain: boolean; + ignoreAgent: boolean; + ignoreRetriever: boolean; + ignoreCustomEvent: boolean; + raiseError: boolean; + awaitHandlers: boolean; + + // Callback handler methods (properties with function signatures) + // Using 'any' for parameters and return types to match LangChain's BaseCallbackHandler exactly + handleLLMStart?: ( + llm: unknown, + prompts: string[], + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleLLMNewToken?: ( + token: string, + idx: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + fields?: unknown, + ) => Promise | unknown; + handleLLMEnd?: ( + output: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleLLMError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleChainStart?: ( + chain: { name?: string }, + inputs: Record, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runType?: string, + runName?: string, + ) => Promise | unknown; + handleChainEnd?: ( + outputs: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleChainError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleToolStart?: ( + tool: { name?: string }, + input: string, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleToolEnd?: (output: unknown, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + handleToolError?: (error: Error, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + + // LangChain class methods (required for BaseCallbackHandler compatibility) + copy(): unknown; + toJSON(): Record; + toJSONNotImplemented(): unknown; +} diff --git a/packages/core/src/utils/langchain/utils.ts b/packages/core/src/utils/langchain/utils.ts new file mode 100644 index 000000000000..8464e71aecb0 --- /dev/null +++ b/packages/core/src/utils/langchain/utils.ts @@ -0,0 +1,424 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; +import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; + +/** + * Assigns an attribute only when the value is neither `undefined` nor `null`. + * + * We keep this tiny helper because call sites are repetitive and easy to miswrite. + * It also preserves falsy-but-valid values like `0` and `""`. + */ +const setIfDefined = (target: Record, key: string, value: unknown): void => { + if (value != null) target[key] = value as SpanAttributeValue; +}; + +/** + * Like `setIfDefined`, but converts the value with `Number()` and skips only when the + * result is `NaN`. This ensures numeric 0 makes it through (unlike truthy checks). + */ +const setNumberIfDefined = (target: Record, key: string, value: unknown): void => { + const n = Number(value); + if (!Number.isNaN(n)) target[key] = n; +}; + +/** + * Converts a value to a string. Avoids double-quoted JSON strings where a plain + * string is desired, but still handles objects/arrays safely. + */ +function asString(v: unknown): string { + if (typeof v === 'string') return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +/** + * Normalizes a single role token to our canonical set. + * + * @param role Incoming role value (free-form, any casing) + * @returns Canonical role: 'user' | 'assistant' | 'system' | 'function' | 'tool' | + */ +function normalizeMessageRole(role: string): string { + const normalized = role.toLowerCase(); + return ROLE_MAP[normalized] ?? normalized; +} + +/** + * Infers a role from a LangChain message constructor name. + * + * Checks for substrings like "System", "Human", "AI", etc. + */ +function normalizeRoleNameFromCtor(name: string): string { + if (name.includes('System')) return 'system'; + if (name.includes('Human')) return 'user'; + if (name.includes('AI') || name.includes('Assistant')) return 'assistant'; + if (name.includes('Function')) return 'function'; + if (name.includes('Tool')) return 'tool'; + return 'user'; +} + +/** + * Returns invocation params from a LangChain `tags` object. + * + * LangChain often passes runtime parameters (model, temperature, etc.) via the + * `tags.invocation_params` bag. If `tags` is an array (LangChain sometimes uses + * string tags), we return `undefined`. + * + * @param tags LangChain tags (string[] or record) + * @returns The `invocation_params` object, if present + */ +export function getInvocationParams(tags?: string[] | Record): Record | undefined { + if (!tags || Array.isArray(tags)) return undefined; + return tags.invocation_params as Record | undefined; +} + +/** + * Normalizes a heterogeneous set of LangChain messages to `{ role, content }`. + * + * Why so many branches? LangChain messages can arrive in several shapes: + * - Message classes with `_getType()` (most reliable) + * - Classes with meaningful constructor names (e.g. `SystemMessage`) + * - Plain objects with `type`, or `{ role, content }` + * - Serialized format with `{ lc: 1, id: [...], kwargs: { content } }` + * We preserve the prioritization to minimize behavioral drift. + * + * @param messages Mixed LangChain messages + * @returns Array of normalized `{ role, content }` + */ +export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<{ role: string; content: string }> { + return messages.map(message => { + // 1) Prefer _getType() when present + const maybeGetType = (message as { _getType?: () => string })._getType; + if (typeof maybeGetType === 'function') { + const messageType = maybeGetType.call(message); + return { + role: normalizeMessageRole(messageType), + content: asString(message.content), + }; + } + + // 2) Then try constructor name (SystemMessage / HumanMessage / ...) + const ctor = (message as { constructor?: { name?: string } }).constructor?.name; + if (ctor) { + return { + role: normalizeMessageRole(normalizeRoleNameFromCtor(ctor)), + content: asString(message.content), + }; + } + + // 3) Then objects with `type` + if (message.type) { + const role = String(message.type).toLowerCase(); + return { + role: normalizeMessageRole(role), + content: asString(message.content), + }; + } + + // 4) Then objects with `{ role, content }` + if (message.role) { + return { + role: normalizeMessageRole(String(message.role)), + content: asString(message.content), + }; + } + + // 5) Serialized LangChain format (lc: 1) + if (message.lc === 1 && message.kwargs) { + const id = message.id; + const messageType = Array.isArray(id) && id.length > 0 ? id[id.length - 1] : ''; + const role = typeof messageType === 'string' ? normalizeRoleNameFromCtor(messageType) : 'user'; + + return { + role: normalizeMessageRole(role), + content: asString(message.kwargs?.content), + }; + } + + // 6) Fallback: treat as user text + return { + role: 'user', + content: asString(message.content), + }; + }); +} + +/** + * Extracts request attributes common to both LLM and ChatModel invocations. + * + * Source precedence: + * 1) `invocationParams` (highest) + * 2) `langSmithMetadata` + * + * Numeric values are set even when 0 (e.g. `temperature: 0`), but skipped if `NaN`. + */ +function extractCommonRequestAttributes( + serialized: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const attrs: Record = {}; + + // Get kwargs if available (from constructor type) + const kwargs = 'kwargs' in serialized ? serialized.kwargs : undefined; + + const temperature = invocationParams?.temperature ?? langSmithMetadata?.ls_temperature ?? kwargs?.temperature; + setNumberIfDefined(attrs, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, temperature); + + const maxTokens = invocationParams?.max_tokens ?? langSmithMetadata?.ls_max_tokens ?? kwargs?.max_tokens; + setNumberIfDefined(attrs, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, maxTokens); + + const topP = invocationParams?.top_p ?? kwargs?.top_p; + setNumberIfDefined(attrs, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, topP); + + const frequencyPenalty = invocationParams?.frequency_penalty; + setNumberIfDefined(attrs, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, frequencyPenalty); + + const presencePenalty = invocationParams?.presence_penalty; + setNumberIfDefined(attrs, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, presencePenalty); + + // LangChain uses `stream`. We only set the attribute if the key actually exists + // (some callbacks report `false` even on streamed requests, this stems from LangChain's callback handler). + if (invocationParams && 'stream' in invocationParams) { + setIfDefined(attrs, GEN_AI_REQUEST_STREAM_ATTRIBUTE, Boolean(invocationParams.stream)); + } + + return attrs; +} + +/** + * Small helper to assemble boilerplate attributes shared by both request extractors. + */ +function baseRequestAttributes( + system: unknown, + modelName: unknown, + operation: 'pipeline' | 'chat', + serialized: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + return { + [GEN_AI_SYSTEM_ATTRIBUTE]: asString(system ?? 'langchain'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operation, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: asString(modelName), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + ...extractCommonRequestAttributes(serialized, invocationParams, langSmithMetadata), + }; +} + +/** + * Extracts attributes for plain LLM invocations (string prompts). + * + * - Operation is tagged as `pipeline` to distinguish from chat-style invocations. + * - When `recordInputs` is true, string prompts are wrapped into `{role:"user"}` + * messages to align with the chat schema used elsewhere. + */ +export function extractLLMRequestAttributes( + llm: LangChainSerialized, + prompts: string[], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const system = langSmithMetadata?.ls_provider; + const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; + + const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); + + if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { + const messages = prompts.map(p => ({ role: 'user', content: p })); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); + } + + return attrs; +} + +/** + * Extracts attributes for ChatModel invocations (array-of-arrays of messages). + * + * - Operation is tagged as `chat`. + * - We flatten LangChain's `LangChainMessage[][]` and normalize shapes into a + * consistent `{ role, content }` array when `recordInputs` is true. + * - Provider system value falls back to `serialized.id?.[2]`. + */ +export function extractChatModelRequestAttributes( + llm: LangChainSerialized, + langChainMessages: LangChainMessage[][], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const system = langSmithMetadata?.ls_provider ?? llm.id?.[2]; + const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; + + const attrs = baseRequestAttributes(system, modelName, 'chat', llm, invocationParams, langSmithMetadata); + + if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { + const normalized = normalizeLangChainMessages(langChainMessages.flat()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(normalized)); + } + + return attrs; +} + +/** + * Scans generations for Anthropic-style `tool_use` items and records them. + * + * LangChain represents some provider messages (e.g., Anthropic) with a `message.content` + * array that may include objects `{ type: 'tool_use', ... }`. We collect and attach + * them as a JSON array on `gen_ai.response.tool_calls` for downstream consumers. + */ +function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record): void { + const toolCalls: unknown[] = []; + const flatGenerations = generations.flat(); + + for (const gen of flatGenerations) { + const content = gen.message?.content; + if (Array.isArray(content)) { + for (const item of content) { + const t = item as { type: string }; + if (t.type === 'tool_use') toolCalls.push(t); + } + } + } + + if (toolCalls.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, asString(toolCalls)); + } +} + +/** + * Adds token usage attributes, supporting both OpenAI (`tokenUsage`) and Anthropic (`usage`) formats. + * - Preserve zero values (0 tokens) by avoiding truthy checks. + * - Compute a total for Anthropic when not explicitly provided. + * - Include cache token metrics when present. + */ +function addTokenUsageAttributes( + llmOutput: LangChainLLMResult['llmOutput'], + attrs: Record, +): void { + if (!llmOutput) return; + + const tokenUsage = llmOutput.tokenUsage as + | { promptTokens?: number; completionTokens?: number; totalTokens?: number } + | undefined; + const anthropicUsage = llmOutput.usage as + | { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + } + | undefined; + + if (tokenUsage) { + setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, tokenUsage.promptTokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, tokenUsage.completionTokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, tokenUsage.totalTokens); + } else if (anthropicUsage) { + setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.input_tokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, anthropicUsage.output_tokens); + + // Compute total when not provided by the provider. + const input = Number(anthropicUsage.input_tokens); + const output = Number(anthropicUsage.output_tokens); + const total = (Number.isNaN(input) ? 0 : input) + (Number.isNaN(output) ? 0 : output); + if (total > 0) setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, total); + + // Extra Anthropic cache metrics (present only when caching is enabled) + if (anthropicUsage.cache_creation_input_tokens !== undefined) + setNumberIfDefined( + attrs, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, + anthropicUsage.cache_creation_input_tokens, + ); + if (anthropicUsage.cache_read_input_tokens !== undefined) + setNumberIfDefined(attrs, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.cache_read_input_tokens); + } +} + +/** + * Extracts response-related attributes based on a `LangChainLLMResult`. + * + * - Records finish reasons when present on generations (e.g., OpenAI) + * - When `recordOutputs` is true, captures textual response content and any + * tool calls. + * - Also propagates model name (`model_name` or `model`), response `id`, and + * `stop_reason` (for providers that use it). + */ +export function extractLlmResponseAttributes( + llmResult: LangChainLLMResult, + recordOutputs: boolean, +): Record | undefined { + if (!llmResult) return; + + const attrs: Record = {}; + + if (Array.isArray(llmResult.generations)) { + const finishReasons = llmResult.generations + .flat() + .map(g => g.generation_info?.finish_reason) + .filter((r): r is string => typeof r === 'string'); + + if (finishReasons.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, asString(finishReasons)); + } + + // Tool calls metadata (names, IDs) are not PII, so capture them regardless of recordOutputs + addToolCallsAttributes(llmResult.generations as LangChainMessage[][], attrs); + + if (recordOutputs) { + const texts = llmResult.generations + .flat() + .map(gen => gen.text ?? gen.message?.content) + .filter(t => typeof t === 'string'); + + if (texts.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, asString(texts)); + } + } + } + + addTokenUsageAttributes(llmResult.llmOutput, attrs); + + const llmOutput = llmResult.llmOutput as { model_name?: string; model?: string; id?: string; stop_reason?: string }; + // Provider model identifier: `model_name` (OpenAI-style) or `model` (others) + const modelName = llmOutput?.model_name ?? llmOutput?.model; + if (modelName) setIfDefined(attrs, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, modelName); + + if (llmOutput?.id) { + setIfDefined(attrs, GEN_AI_RESPONSE_ID_ATTRIBUTE, llmOutput.id); + } + + if (llmOutput?.stop_reason) { + setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(llmOutput.stop_reason)); + } + + return attrs; +} diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 4ecfad625062..bb099199772c 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -19,6 +19,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -191,10 +192,12 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 8f353e88d394..747a3c105449 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -6,6 +6,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -196,7 +197,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); + span.setAttribute('gen_ai.prompt', truncatedPrompt); } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index a5c68184e03b..a32f073eeb75 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -78,7 +78,7 @@ describe('createConsolaReporter', () => { level: 'error', message: 'This is an error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.tag': 'test', 'consola.type': 'error', 'consola.level': 0, @@ -98,7 +98,7 @@ describe('createConsolaReporter', () => { level: 'warn', message: 'This is a warning', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'warn', }, }); @@ -116,7 +116,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'This is info', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -134,7 +134,7 @@ describe('createConsolaReporter', () => { level: 'debug', message: 'Debug message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'debug', }, }); @@ -152,7 +152,7 @@ describe('createConsolaReporter', () => { level: 'trace', message: 'Trace message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'trace', }, }); @@ -170,7 +170,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'fatal', }, }); @@ -189,7 +189,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Hello world 123 {"key":"value"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -210,7 +210,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Message {"self":"[Circular ~]"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -228,7 +228,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.level': 0, }, }); @@ -257,7 +257,7 @@ describe('createConsolaReporter', () => { level: expectedLevel, message: `Test ${type} message`, attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': type, }, }); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index db52cf357a16..02e55c45a7ba 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 40924e8abd31..26b3a172090a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,7 +87,6 @@ "@sentry/react": "10.21.0", "@sentry/vercel-edge": "10.21.0", "@sentry/webpack-plugin": "^4.3.0", - "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" diff --git a/packages/nextjs/src/common/nextSpanAttributes.ts b/packages/nextjs/src/common/nextSpanAttributes.ts new file mode 100644 index 000000000000..8b9f4a9d1374 --- /dev/null +++ b/packages/nextjs/src/common/nextSpanAttributes.ts @@ -0,0 +1,3 @@ +export const ATTR_NEXT_SPAN_TYPE = 'next.span_type'; +export const ATTR_NEXT_SPAN_NAME = 'next.span_name'; +export const ATTR_NEXT_ROUTE = 'next.route'; diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index c60563ccd241..3125102e9656 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -1,6 +1,5 @@ import commonjs from '@rollup/plugin-commonjs'; import { stringMatchesSomePattern } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import type { RollupBuild, RollupError } from 'rollup'; @@ -165,9 +164,7 @@ export default function wrappingLoader( if (!showedMissingAsyncStorageModuleWarning) { // eslint-disable-next-line no-console console.warn( - `${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan( - 'RequestAsyncStorage', - )} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`, + "[@sentry/nextjs] The Sentry SDK could not access the 'RequestAsyncStorage' module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.", ); showedMissingAsyncStorageModuleWarning = true; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 14f064ae2b0a..4484b1194bd2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -2,7 +2,6 @@ /* eslint-disable max-lines */ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; @@ -245,11 +244,7 @@ export function constructWebpackConfigFunction({ vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons; if (vercelCronsConfig) { debug.log( - `${chalk.cyan( - 'info', - )} - Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the ${chalk.bold.cyan( - 'automaticVercelMonitors', - )} option to false in you Next.js config.`, + "[@sentry/nextjs] Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the 'automaticVercelMonitors' option to false in you Next.js config.", ); } } @@ -259,9 +254,7 @@ export function constructWebpackConfigFunction({ } else { // log but noop debug.error( - `${chalk.red( - 'error', - )} - Sentry failed to read vercel.json for automatic cron job monitoring instrumentation`, + '[@sentry/nextjs] Failed to read vercel.json for automatic cron job monitoring instrumentation', e, ); } @@ -344,11 +337,7 @@ export function constructWebpackConfigFunction({ ) { // eslint-disable-next-line no-console console.log( - `${chalk.yellow( - 'warn', - )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( - 'global-error.js', - )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)`, + "[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)", ); showedMissingGlobalErrorWarningMsg = true; } @@ -541,9 +530,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', - ), + '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', ); } return; @@ -552,9 +539,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!instrumentationFile.includes('onRequestError')) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', - ), + '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', ); } } diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 5ce23e6a9460..aa6210c2ff6a 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -31,6 +31,7 @@ import { getScopesFromContext } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, @@ -169,7 +170,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. - if (typeof spanAttributes?.['next.route'] === 'string') { + if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { const rootSpanAttributes = spanToJSON(rootSpan).data; // Only hoist the http.route attribute if the transaction doesn't already have it if ( @@ -177,17 +178,27 @@ export function init(options: NodeOptions): NodeClient | undefined { (rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) && !rootSpanAttributes?.[ATTR_HTTP_ROUTE] ) { - const route = spanAttributes['next.route'].replace(/\/route$/, ''); + const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, ''); rootSpan.updateName(route); rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); // Preserving the original attribute despite internally not depending on it - rootSpan.setAttribute('next.route', route); + rootSpan.setAttribute(ATTR_NEXT_ROUTE, route); } } + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') { + const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME]; + if (typeof middlewareName === 'string') { + rootSpan.updateName(middlewareName); + rootSpan.setAttribute(ATTR_HTTP_ROUTE, middlewareName); + rootSpan.setAttribute(ATTR_NEXT_SPAN_NAME, middlewareName); + } + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); + } + // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans // with patterns (e.g. http.server spans) that will produce confusing data. - if (spanAttributes?.['next.span_type'] !== undefined) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } @@ -197,7 +208,7 @@ export function init(options: NodeOptions): NodeClient | undefined { } // We want to fork the isolation scope for incoming requests - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); @@ -320,7 +331,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // Enhance route handler transactions if ( event.type === 'transaction' && - event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest' + event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' ) { event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; event.contexts.trace.op = 'http.server'; @@ -333,14 +344,15 @@ export function init(options: NodeOptions): NodeClient | undefined { const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; // eslint-disable-next-line deprecation/deprecation const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route']; + const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; + const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - if (typeof method === 'string' && typeof route === 'string') { + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { const cleanRoute = route.replace(/\/route$/, ''); event.transaction = `${method} ${cleanRoute}`; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; // Preserve next.route in case it did not get hoisted - event.contexts.trace.data['next.route'] = cleanRoute; + event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; } // backfill transaction name for pages that would otherwise contain unparameterized routes @@ -348,6 +360,15 @@ export function init(options: NodeOptions): NodeClient | undefined { event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; } + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + const normalizedName = `middleware ${middlewareMatch[1]}`; + event.transaction = normalizedName; + event.contexts.trace.op = 'http.server.middleware'; + } + // Next.js overrides transaction names for page loads that throw an error // but we want to keep the original target name if (event.transaction === 'GET /_error' && target) { diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index dfc51d5022ff..21eeff64769e 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -81,9 +81,22 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; +type PinoResult = { + level?: string; + time?: string; + pid?: number; + hostname?: string; +} & Record; + +function stripIgnoredFields(result: PinoResult): PinoResult { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { level, time, pid, hostname, ...rest } = result; + return rest; +} + const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { - autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, + autoInstrument: userOptions.autoInstrument !== false, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; @@ -112,27 +125,23 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { - ...obj, - 'sentry.origin': 'auto.logging.pino', + ...resultObj, + 'sentry.origin': 'auto.log.pino', 'pino.logger.level': levelNumber, }; - const parsedResult = JSON.parse(result) as { name?: string }; - - if (parsedResult.name) { - attributes['pino.logger.name'] = parsedResult.name; - } - _INTERNAL_captureLog({ level, message, attributes }); } @@ -153,8 +162,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial { const { self, arguments: args, result } = data as { self: Pino; arguments: PinoHookArgs; result: string }; - onPinoStart(self, args, result); + onPinoStart(self, args, JSON.parse(result)); }); integratedChannel.end.subscribe(data => { @@ -174,7 +183,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial { /** * Whether breadcrumbs should be recorded for requests. * Defaults to true @@ -106,6 +106,8 @@ function getConfigWithDefaults(options: Partial = {}): UndiciI [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', }; }, + requestHook: options.requestHook, + responseHook: options.responseHook, } satisfies UndiciInstrumentationConfig; return instrumentationConfig; diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts index 649a7089289b..ceb521d54fa3 100644 --- a/packages/node/src/integrations/tracing/firebase/firebase.ts +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { captureException, defineIntegration, flush, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; @@ -11,6 +11,24 @@ const config: FirebaseInstrumentationConfig = { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); }, + functions: { + requestHook: span => { + addOriginToSpan(span, 'auto.firebase.otel.functions'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request'); + }, + errorHook: async (_, error) => { + if (error) { + captureException(error, { + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }); + await flush(2000); + } + }, + }, }; export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts index ad67ea701079..724005e6f9ed 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -1,10 +1,12 @@ import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; import { SDK_VERSION } from '@sentry/core'; import { patchFirestore } from './patches/firestore'; +import { patchFunctions } from './patches/functions'; import type { FirebaseInstrumentationConfig } from './types'; const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ +const functionsSupportedVersions = ['>=6.0.0 <7']; // firebase-functions v2 /** * Instrumentation for Firebase services, specifically Firestore. @@ -31,6 +33,7 @@ export class FirebaseInstrumentation extends InstrumentationBase {}; + let responseHook: ResponseHook = () => {}; + const errorHook = config.functions?.errorHook; + const configRequestHook = config.functions?.requestHook; + const configResponseHook = config.functions?.responseHook; + + if (typeof configResponseHook === 'function') { + responseHook = (span: Span, err: unknown) => { + safeExecuteInTheMiddle( + () => configResponseHook(span, err), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + if (typeof configRequestHook === 'function') { + requestHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configRequestHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFunctionsCJS = new InstrumentationNodeModuleDefinition('firebase-functions', functionsSupportedVersions); + const modulesToInstrument = [ + { name: 'firebase-functions/lib/v2/providers/https.js', triggerType: 'function' }, + { name: 'firebase-functions/lib/v2/providers/firestore.js', triggerType: 'firestore' }, + { name: 'firebase-functions/lib/v2/providers/scheduler.js', triggerType: 'scheduler' }, + { name: 'firebase-functions/lib/v2/storage.js', triggerType: 'storage' }, + ] as const; + + modulesToInstrument.forEach(({ name, triggerType }) => { + moduleFunctionsCJS.files.push( + new InstrumentationNodeModuleFile( + name, + functionsSupportedVersions, + moduleExports => + wrapCommonFunctions( + moduleExports, + wrap, + unwrap, + tracer, + { requestHook, responseHook, errorHook }, + triggerType, + ), + moduleExports => unwrapCommonFunctions(moduleExports, unwrap), + ), + ); + }); + + return moduleFunctionsCJS; +} + +/** + * Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation + * + * @param tracer - Opentelemetry Tracer + * @param functionsConfig - Firebase instrumentation config + * @param triggerType - Type of trigger + * @returns A function that patches the function + */ +export function patchV2Functions( + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: string, +): (original: T) => (...args: OverloadedParameters) => ReturnType { + return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters) => ReturnType { + return function (this: FirebaseInstrumentation, ...args: OverloadedParameters): ReturnType { + const handler = typeof args[0] === 'function' ? args[0] : args[1]; + const documentOrOptions = typeof args[0] === 'function' ? undefined : args[0]; + + if (!handler) { + return original.call(this, ...args); + } + + const wrappedHandler = async function (this: unknown, ...handlerArgs: unknown[]): Promise { + const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + const span = tracer.startSpan(`firebase.function.${triggerType}`, { + kind: SpanKind.SERVER, + }); + + const attributes: SpanAttributes = { + 'faas.name': functionName, + 'faas.trigger': triggerType, + 'faas.provider': 'firebase', + }; + + if (process.env.GCLOUD_PROJECT) { + attributes['cloud.project_id'] = process.env.GCLOUD_PROJECT; + } + + if (process.env.EVENTARC_CLOUD_EVENT_SOURCE) { + attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE; + } + + span.setAttributes(attributes); + functionsConfig?.requestHook?.(span); + + // Can be changed to safeExecuteInTheMiddleAsync once following is merged and released + // https://github.com/open-telemetry/opentelemetry-js/pull/6032 + return context.with(trace.setSpan(context.active(), span), async () => { + let error: Error | undefined; + let result: T | undefined; + + try { + result = await handler.apply(this, handlerArgs); + } catch (e) { + error = e as Error; + } + + functionsConfig?.responseHook?.(span, error); + + if (error) { + span.recordException(error); + } + + span.end(); + + if (error) { + await functionsConfig?.errorHook?.(span, error); + throw error; + } + + return result; + }); + }; + + if (documentOrOptions) { + return original.call(this, documentOrOptions, wrappedHandler); + } else { + return original.call(this, wrappedHandler); + } + }; + }; +} + +function wrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + wrap: InstrumentationBase['_wrap'], + unwrap: InstrumentationBase['_unwrap'], + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: 'function' | 'firestore' | 'scheduler' | 'storage', +): AvailableFirebaseFunctions { + unwrapCommonFunctions(moduleExports, unwrap); + + switch (triggerType) { + case 'function': + wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsConfig, 'http.request')); + wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsConfig, 'http.call')); + break; + + case 'firestore': + wrap(moduleExports, 'onDocumentCreated', patchV2Functions(tracer, functionsConfig, 'firestore.document.created')); + wrap(moduleExports, 'onDocumentUpdated', patchV2Functions(tracer, functionsConfig, 'firestore.document.updated')); + wrap(moduleExports, 'onDocumentDeleted', patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted')); + wrap(moduleExports, 'onDocumentWritten', patchV2Functions(tracer, functionsConfig, 'firestore.document.written')); + wrap( + moduleExports, + 'onDocumentCreatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.created'), + ); + wrap( + moduleExports, + 'onDocumentUpdatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'), + ); + + wrap( + moduleExports, + 'onDocumentDeletedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'), + ); + + wrap( + moduleExports, + 'onDocumentWrittenWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.written'), + ); + break; + + case 'scheduler': + wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsConfig, 'scheduler.scheduled')); + break; + + case 'storage': + wrap(moduleExports, 'onObjectFinalized', patchV2Functions(tracer, functionsConfig, 'storage.object.finalized')); + wrap(moduleExports, 'onObjectArchived', patchV2Functions(tracer, functionsConfig, 'storage.object.archived')); + wrap(moduleExports, 'onObjectDeleted', patchV2Functions(tracer, functionsConfig, 'storage.object.deleted')); + wrap( + moduleExports, + 'onObjectMetadataUpdated', + patchV2Functions(tracer, functionsConfig, 'storage.object.metadataUpdated'), + ); + break; + } + + return moduleExports; +} + +function unwrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + unwrap: InstrumentationBase['_unwrap'], +): AvailableFirebaseFunctions { + const methods: (keyof AvailableFirebaseFunctions)[] = [ + 'onSchedule', + 'onRequest', + 'onCall', + 'onObjectFinalized', + 'onObjectArchived', + 'onObjectDeleted', + 'onObjectMetadataUpdated', + 'onDocumentCreated', + 'onDocumentUpdated', + 'onDocumentDeleted', + 'onDocumentWritten', + 'onDocumentCreatedWithAuthContext', + 'onDocumentUpdatedWithAuthContext', + 'onDocumentDeletedWithAuthContext', + 'onDocumentWrittenWithAuthContext', + ]; + + for (const method of methods) { + if (isWrapped(moduleExports[method])) { + unwrap(moduleExports, method); + } + } + return moduleExports; +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts index ecc48bc09498..ead830fa2c1a 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/types.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -88,8 +88,19 @@ export interface FirestoreSettings { */ export interface FirebaseInstrumentationConfig extends InstrumentationConfig { firestoreSpanCreationHook?: FirestoreSpanCreationHook; + functions?: FunctionsConfig; } +export interface FunctionsConfig { + requestHook?: RequestHook; + responseHook?: ResponseHook; + errorHook?: ErrorHook; +} + +export type RequestHook = (span: Span) => void; +export type ResponseHook = (span: Span, error?: unknown) => void; +export type ErrorHook = (span: Span, error?: unknown) => Promise | void; + export interface FirestoreSpanCreationHook { (span: Span): void; } @@ -117,3 +128,40 @@ export type AddDocType = ( export type DeleteDocType = ( reference: DocumentReference, ) => Promise; + +export type OverloadedParameters = T extends { + (...args: infer A1): unknown; + (...args: infer A2): unknown; +} + ? A1 | A2 + : T extends (...args: infer A) => unknown + ? A + : unknown; + +/** + * A bare minimum of how Cloud Functions for Firebase (v2) are defined. + */ +export type FirebaseFunctions = + | ((handler: () => Promise | unknown) => (...args: unknown[]) => Promise | unknown) + | (( + documentOrOptions: string | string[] | Record, + handler: () => Promise | unknown, + ) => (...args: unknown[]) => Promise | unknown); + +export type AvailableFirebaseFunctions = { + onRequest: FirebaseFunctions; + onCall: FirebaseFunctions; + onDocumentCreated: FirebaseFunctions; + onDocumentUpdated: FirebaseFunctions; + onDocumentDeleted: FirebaseFunctions; + onDocumentWritten: FirebaseFunctions; + onDocumentCreatedWithAuthContext: FirebaseFunctions; + onDocumentUpdatedWithAuthContext: FirebaseFunctions; + onDocumentDeletedWithAuthContext: FirebaseFunctions; + onDocumentWrittenWithAuthContext: FirebaseFunctions; + onSchedule: FirebaseFunctions; + onObjectFinalized: FirebaseFunctions; + onObjectArchived: FirebaseFunctions; + onObjectDeleted: FirebaseFunctions; + onObjectMetadataUpdated: FirebaseFunctions; +}; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dd9d9ac8df2b..2782d7907349 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -13,6 +13,7 @@ import { hapiIntegration, instrumentHapi } from './hapi'; import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; +import { instrumentLangChain, langChainIntegration } from './langchain'; import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; @@ -56,6 +57,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { firebaseIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + langChainIntegration(), ]; } @@ -93,5 +95,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, + instrumentLangChain, ]; } diff --git a/packages/node/src/integrations/tracing/langchain/index.ts b/packages/node/src/integrations/tracing/langchain/index.ts new file mode 100644 index 000000000000..e575691b930f --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/index.ts @@ -0,0 +1,107 @@ +import type { IntegrationFn, LangChainOptions } from '@sentry/core'; +import { defineIntegration, LANGCHAIN_INTEGRATION_NAME } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryLangChainInstrumentation } from './instrumentation'; + +export const instrumentLangChain = generateInstrumentOnce( + LANGCHAIN_INTEGRATION_NAME, + options => new SentryLangChainInstrumentation(options), +); + +const _langChainIntegration = ((options: LangChainOptions = {}) => { + return { + name: LANGCHAIN_INTEGRATION_NAME, + setupOnce() { + instrumentLangChain(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for LangChain. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments LangChain runnable instances + * to capture telemetry data by injecting Sentry callback handlers into all LangChain calls. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * Sentry.init({ + * integrations: [Sentry.langChainIntegration()], + * sendDefaultPii: true, // Enable to record inputs/outputs + * }); + * + * // LangChain calls are automatically instrumented + * const model = new ChatOpenAI(); + * await model.invoke("What is the capital of France?"); + * ``` + * + * ## Manual Callback Handler + * + * You can also manually add the Sentry callback handler alongside other callbacks: + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * const sentryHandler = Sentry.createLangChainCallbackHandler({ + * recordInputs: true, + * recordOutputs: true + * }); + * + * const model = new ChatOpenAI(); + * await model.invoke( + * "What is the capital of France?", + * { callbacks: [sentryHandler, myOtherCallback] } + * ); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record input messages/prompts (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + * ## Supported Events + * + * The integration captures the following LangChain lifecycle events: + * - LLM/Chat Model: start, end, error + * - Chain: start, end, error + * - Tool: start, end, error + * + */ +export const langChainIntegration = defineIntegration(_langChainIntegration); diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts new file mode 100644 index 000000000000..f171a2dfb022 --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -0,0 +1,214 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import type { LangChainOptions } from '@sentry/core'; +import { createLangChainCallbackHandler, getClient, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.1.0 <1.0.0']; + +type LangChainInstrumentationOptions = InstrumentationConfig & LangChainOptions; + +/** + * Represents the patched shape of LangChain provider package exports + */ +interface PatchedLangChainExports { + [key: string]: unknown; +} + +/** + * Augments a callback handler list with Sentry's handler if not already present + */ +function augmentCallbackHandlers(handlers: unknown, sentryHandler: unknown): unknown { + // Handle null/undefined - return array with just our handler + if (!handlers) { + return [sentryHandler]; + } + + // If handlers is already an array + if (Array.isArray(handlers)) { + // Check if our handler is already in the list + if (handlers.includes(sentryHandler)) { + return handlers; + } + // Add our handler to the list + return [...handlers, sentryHandler]; + } + + // If it's a single handler object, convert to array + if (typeof handlers === 'object') { + return [handlers, sentryHandler]; + } + + // Unknown type - return original + return handlers; +} + +/** + * Wraps Runnable methods (invoke, stream, batch) to inject Sentry callbacks at request time + * Uses a Proxy to intercept method calls and augment the options.callbacks + */ +function wrapRunnableMethod( + originalMethod: (...args: unknown[]) => unknown, + sentryHandler: unknown, + _methodName: string, +): (...args: unknown[]) => unknown { + return new Proxy(originalMethod, { + apply(target, thisArg, args: unknown[]): unknown { + // LangChain Runnable method signatures: + // invoke(input, options?) - options contains callbacks + // stream(input, options?) - options contains callbacks + // batch(inputs, options?) - options contains callbacks + + // Options is typically the second argument + const optionsIndex = 1; + let options = args[optionsIndex] as Record | undefined; + + // If options don't exist or aren't an object, create them + if (!options || typeof options !== 'object' || Array.isArray(options)) { + options = {}; + args[optionsIndex] = options; + } + + // Inject our callback handler into options.callbacks (request time callbacks) + const existingCallbacks = options.callbacks; + const augmentedCallbacks = augmentCallbackHandlers(existingCallbacks, sentryHandler); + options.callbacks = augmentedCallbacks; + + // Call original method with augmented options + return Reflect.apply(target, thisArg, args); + }, + }) as (...args: unknown[]) => unknown; +} + +/** + * Sentry LangChain instrumentation using OpenTelemetry. + */ +export class SentryLangChainInstrumentation extends InstrumentationBase { + public constructor(config: LangChainInstrumentationOptions = {}) { + super('@sentry/instrumentation-langchain', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + * We patch the BaseChatModel class methods to inject callbacks + * + * We hook into provider packages (@langchain/anthropic, @langchain/openai, etc.) + * because @langchain/core is often bundled and not loaded as a separate module + */ + public init(): InstrumentationModuleDefinition | InstrumentationModuleDefinition[] { + const modules: InstrumentationModuleDefinition[] = []; + + // Hook into common LangChain provider packages + const providerPackages = [ + '@langchain/anthropic', + '@langchain/openai', + '@langchain/google-genai', + '@langchain/mistralai', + '@langchain/google-vertexai', + '@langchain/groq', + ]; + + for (const packageName of providerPackages) { + // In CJS, LangChain packages re-export from dist/index.cjs files. + // Patching only the root module sometimes misses the real implementation or + // gets overwritten when that file is loaded. We add a file-level patch so that + // _patch runs again on the concrete implementation + modules.push( + new InstrumentationNodeModuleDefinition( + packageName, + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + `${packageName}/dist/index.cjs`, + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + ); + } + + return modules; + } + + /** + * Core patch logic - patches chat model methods to inject Sentry callbacks + * This is called when a LangChain provider package is loaded + */ + private _patch(exports: PatchedLangChainExports): PatchedLangChainExports | void { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const config = this.getConfig(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordInputs = config?.recordInputs ?? defaultPii; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordOutputs = config?.recordOutputs ?? defaultPii; + + // Create a shared handler instance + const sentryHandler = createLangChainCallbackHandler({ + recordInputs, + recordOutputs, + }); + + // Patch Runnable methods to inject callbacks at request time + // This directly manipulates options.callbacks that LangChain uses + this._patchRunnableMethods(exports, sentryHandler); + + return exports; + } + + /** + * Patches chat model methods (invoke, stream, batch) to inject Sentry callbacks + * Finds a chat model class from the provider package exports and patches its prototype methods + */ + private _patchRunnableMethods(exports: PatchedLangChainExports, sentryHandler: unknown): void { + // Known chat model class names for each provider + const knownChatModelNames = [ + 'ChatAnthropic', + 'ChatOpenAI', + 'ChatGoogleGenerativeAI', + 'ChatMistralAI', + 'ChatVertexAI', + 'ChatGroq', + ]; + + // Find a chat model class in the exports by checking known class names + const chatModelClass = Object.values(exports).find(exp => { + if (typeof exp !== 'function') { + return false; + } + return knownChatModelNames.includes(exp.name); + }) as { prototype: unknown; name: string } | undefined; + + if (!chatModelClass) { + return; + } + + // Patch directly on chatModelClass.prototype + const targetProto = chatModelClass.prototype as Record; + + // Patch the methods (invoke, stream, batch) + // All chat model instances will inherit these patched methods + const methodsToPatch = ['invoke', 'stream', 'batch'] as const; + + for (const methodName of methodsToPatch) { + const method = targetProto[methodName]; + if (typeof method === 'function') { + targetProto[methodName] = wrapRunnableMethod( + method as (...args: unknown[]) => unknown, + sentryHandler, + methodName, + ); + } + } + } +} diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts new file mode 100644 index 000000000000..6900e1431ee7 --- /dev/null +++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts @@ -0,0 +1,127 @@ +import { getTraceMetaTags } from '@sentry/core'; +import { PassThrough } from 'stream'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; + +vi.mock('@opentelemetry/core', () => ({ + RPCType: { HTTP: 'http' }, + getRPCMetadata: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), +})); + +describe('getMetaTagTransformer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('should inject meta tags before closing head tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should not modify chunks without head closing tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toBe('Test'); + expect(outputData).not.toContain('sentry-trace'); + expect(getTraceMetaTags).not.toHaveBeenCalled(); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should handle buffer input', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(Buffer.from('Test')); + transformer.end(); + })); + + test('should handle multiple chunks', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).toContain('Test content'); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(''); + transformer.write('Test content'); + transformer.write(''); + transformer.end(); + })); +}); diff --git a/packages/react-router/test/server/getMetaTagTransformer.ts b/packages/react-router/test/server/getMetaTagTransformer.ts deleted file mode 100644 index 16334888627c..000000000000 --- a/packages/react-router/test/server/getMetaTagTransformer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getTraceMetaTags } from '@sentry/core'; -import { PassThrough } from 'stream'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; - -vi.mock('@opentelemetry/core', () => ({ - RPCType: { HTTP: 'http' }, - getRPCMetadata: vi.fn(), -})); - -vi.mock('@sentry/core', () => ({ - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - getTraceMetaTags: vi.fn(), -})); - -describe('getMetaTagTransformer', () => { - beforeEach(() => { - vi.clearAllMocks(); - (getTraceMetaTags as unknown as ReturnType).mockReturnValue( - '', - ); - }); - - test('should inject meta tags before closing head tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - expect(outputData).not.toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should not modify chunks without head closing tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toBe('Test'); - expect(getTraceMetaTags).toHaveBeenCalled(); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should handle buffer input', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write(Buffer.from('Test')); - bodyStream.end(); - }); -}); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index bf57fdbd74dc..a6e55f1a967c 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -8,7 +8,7 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; +import type { Client, Integration, Span } from '@sentry/core'; import { addNonEnumerableProperty, debug, @@ -41,14 +41,7 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { - getNormalizedName, - initializeRouterUtils, - locationIsInsideDescendantRoute, - prefixWithSlash, - rebuildRoutePathFromAllRoutes, - resolveRouteNameAndSource, -} from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -106,7 +99,8 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; // Keeping as a global variable for cross-usage in multiple functions -const allRoutes = new Set(); +// only exported for testing purposes +export const allRoutes = new Set(); /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -667,7 +661,7 @@ export function handleNavigation(opts: { // Cross usage can result in multiple navigation spans being created without this check if (!isAlreadyInNavigationSpan) { - startBrowserTracingNavigationSpan(client, { + const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, @@ -675,11 +669,17 @@ export function handleNavigation(opts: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, }, }); + + // Patch navigation span to handle early cancellation (e.g., document.hidden) + if (navigationSpan) { + patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); + } } } } -function addRoutesToAllRoutes(routes: RouteObject[]): void { +/* Only exported for testing purposes */ +export function addRoutesToAllRoutes(routes: RouteObject[]): void { routes.forEach(route => { const extractedChildRoutes = getChildRoutesRecursively(route); @@ -727,29 +727,104 @@ function updatePageloadTransaction({ : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); if (branches) { - let name, - source: TransactionSource = 'url'; - - const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); - - if (isInDescendantRoute) { - name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location)); - source = 'route'; - } - - if (!isInDescendantRoute || !name) { - [name, source] = getNormalizedName(routes, location, branches, basename); - } + const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename); getCurrentScope().setTransactionName(name || '/'); if (activeRootSpan) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + + // Patch span.end() to ensure we update the name one last time before the span is sent + patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); } } } +/** + * Patches the span.end() method to update the transaction name one last time before the span is sent. + * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. + */ +function patchSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, + spanType: 'pageload' | 'navigation', +): void { + const patchedPropertyName = `__sentry_${spanType}_end_patched__` as const; + const hasEndBeenPatched = (span as unknown as Record)?.[patchedPropertyName]; + + if (hasEndBeenPatched || !span.end) { + return; + } + + const originalEnd = span.end.bind(span); + + span.end = function patchedEnd(...args) { + try { + // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) + const spanJson = spanToJSON(span); + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (currentSource !== 'route') { + // Last chance to update the transaction name with the latest route info + // Use the live global allRoutes Set to include any lazy routes loaded after patching + const currentAllRoutes = Array.from(allRoutes); + const branches = _matchRoutes( + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + location, + basename, + ) as unknown as RouteMatch[]; + + if (branches) { + const [name, source] = resolveRouteNameAndSource( + location, + routes, + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + branches, + basename, + ); + + // Only update if we have a valid name + if (name && (spanType === 'pageload' || !spanJson.timestamp)) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } + } + } catch (error) { + // Silently catch errors to ensure span.end() is always called + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } + + return originalEnd(...args); + }; + + // Mark this span as having its end() method patched to prevent duplicate patching + addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); +} + +function patchPageloadSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); +} + +function patchNavigationSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( Routes: R, diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index c0750c17c57c..d6501d0e4dbf 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -45,21 +45,27 @@ export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch within ) */ +export function routeIsDescendant(route: RouteObject): boolean { return !!(!route.children && route.element && route.path?.endsWith('/*')); } function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { - const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; - - const formattedPath = - // If the path ends with a slash, remove it - reconstructedPath[reconstructedPath.length - 1] === '/' - ? reconstructedPath.slice(0, -1) - : // If the path ends with a wildcard, remove it - reconstructedPath.slice(-2) === '/*' - ? reconstructedPath.slice(0, -1) - : reconstructedPath; + const reconstructedPath = + pathBuilder && pathBuilder.length > 0 + ? pathBuilder + : _stripBasename + ? stripBasenameFromPathname(pathname, basename) + : pathname; + + let formattedPath = + // If the path ends with a wildcard suffix, remove both the slash and the asterisk + reconstructedPath.slice(-2) === '/*' ? reconstructedPath.slice(0, -2) : reconstructedPath; + + // If the path ends with a slash, remove it (but keep single '/') + if (formattedPath.length > 1 && formattedPath[formattedPath.length - 1] === '/') { + formattedPath = formattedPath.slice(0, -1); + } return [formattedPath, 'route']; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 0eeeeb342287..bad264d3d6b5 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -10,6 +10,7 @@ import { createReactRouterV6CompatibleTracingIntegration, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; +import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -47,6 +48,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ initializeRouterUtils: vi.fn(), getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), + routeIsDescendant: vi.fn(() => false), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -140,4 +142,231 @@ describe('reactrouter-compat-utils/instrumentation', () => { expect(typeof integration.afterAllSetup).toBe('function'); }); }); + + describe('span.end() patching for early cancellation', () => { + it('should update transaction name when span.end() is called during cancellation', () => { + const mockEnd = vi.fn(); + let patchedEnd: ((...args: any[]) => any) | null = null; + + const updateNameMock = vi.fn(); + const setAttributeMock = vi.fn(); + + const testSpan = { + updateName: updateNameMock, + setAttribute: setAttributeMock, + get end() { + return patchedEnd || mockEnd; + }, + set end(fn: (...args: any[]) => any) { + patchedEnd = fn; + }, + } as unknown as Span; + + // Simulate the patching behavior + const originalEnd = testSpan.end.bind(testSpan); + (testSpan as any).end = function patchedEndFn(...args: any[]) { + // This simulates what happens in the actual implementation + updateNameMock('Updated Route'); + setAttributeMock('sentry.source', 'route'); + return originalEnd(...args); + }; + + // Call the patched end + testSpan.end(12345); + + expect(updateNameMock).toHaveBeenCalledWith('Updated Route'); + expect(setAttributeMock).toHaveBeenCalledWith('sentry.source', 'route'); + expect(mockEnd).toHaveBeenCalledWith(12345); + }); + }); +}); + +describe('addRoutesToAllRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + allRoutes.clear(); + }); + + it('should add simple routes without nesting', () => { + const routes = [ + { path: '/', element:

}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toHaveLength(3); + expect(allRoutesArr).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/' }), + expect.objectContaining({ path: '/user/:id' }), + expect.objectContaining({ path: '/group/:group/:user?' }), + ]), + ); + + // Verify exact structure matches manual testing results + allRoutesArr.forEach(route => { + expect(route).toHaveProperty('element'); + expect(route.element).toHaveProperty('props'); + }); + }); + + it('should handle complex nested routes with multiple levels', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + // v1 routes ---- + { + path: '/v1/post/:post', + element:
, + children: [ + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + ], + }, + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + { element:
Edit Post
, path: 'edit' }, + // v2 routes --- + { + path: '/v2/post/:post', + element: expect.objectContaining({ type: 'div', props: {} }), + children: [ + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ], + }, + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ]); + }); + + it('should handle routes with nested index routes', () => { + const routes = [ + { + path: '/dashboard', + element:
, + children: [ + { index: true, element:
Dashboard Index
}, + { path: 'settings', element:
Settings
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/dashboard', + element: expect.objectContaining({ type: 'div' }), + children: [ + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ], + }, + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ]); + }); + + it('should handle deeply nested routes with layout wrappers', () => { + const routes = [ + { + path: '/', + element:
Root
, + children: [ + { path: 'dashboard', element:
Dashboard
}, + { + element:
AuthLayout
, + children: [{ path: 'login', element:
Login
}], + }, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/', + element: expect.objectContaining({ type: 'div', props: { children: 'Root' } }), + children: [ + { + path: 'dashboard', + element: expect.objectContaining({ type: 'div', props: { children: 'Dashboard' } }), + }, + { + element: expect.objectContaining({ type: 'div', props: { children: 'AuthLayout' } }), + children: [ + { + path: 'login', + element: expect.objectContaining({ type: 'div', props: { children: 'Login' } }), + }, + ], + }, + ], + }, + { element:
Dashboard
, path: 'dashboard' }, + { + children: [{ element:
Login
, path: 'login' }], + element:
AuthLayout
, + }, + { element:
Login
, path: 'login' }, + ]); + }); + + it('should not duplicate routes when called multiple times', () => { + const routes = [ + { path: '/', element:
}, + { path: '/about', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const firstCount = allRoutes.size; + + addRoutesToAllRoutes(routes); + const secondCount = allRoutes.size; + + expect(firstCount).toBe(secondCount); + }); }); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 91885940db31..9ff48e7450bc 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -436,7 +436,7 @@ describe('reactrouter-compat-utils/utils', () => { ]; const result = getNormalizedName(routes, location, branches, ''); - expect(result).toEqual(['', 'route']); + expect(result).toEqual(['/', 'route']); }); it('should handle simple route path', () => { diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d8362ff31c98..7a73234f535e 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -95,6 +95,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, logger, } from '@sentry/core'; diff --git a/yarn.lock b/yarn.lock index c0bc7ba27923..06f8d3741128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,13 @@ dependencies: json-schema-to-ts "^3.1.1" +"@anthropic-ai/sdk@^0.65.0": + version "0.65.0" + resolved "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz#3f464fe2029eacf8e7e7fb8197579d00c8ca7502" + integrity sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw== + dependencies: + json-schema-to-ts "^3.1.1" + "@apm-js-collab/code-transformer@^0.8.0", "@apm-js-collab/code-transformer@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz#a3160f16d1c4df9cb81303527287ad18d00994d1" @@ -2678,6 +2685,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + "@cloudflare/kv-asset-handler@0.4.0", "@cloudflare/kv-asset-handler@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz#a8588c6a2e89bb3e87fb449295a901c9f6d3e1bf" @@ -2873,14 +2885,6 @@ "@deno/shim-deno-test" "^0.5.0" which "^4.0.0" -"@dependents/detective-less@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-4.1.0.tgz#4a979ee7a6a79eb33602862d6a1263e30f98002e" - integrity sha512-KrkT6qO5NxqNfy68sBl6CTSoJ4SNDIS5iQArkibhlbGU4LaDukZ3q2HIkh8aUKDio6o4itU4xDR7t82Y2eP1Bg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - "@dependents/detective-less@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-5.0.1.tgz#e6c5b502f0d26a81da4170c1ccd848a6eaa68470" @@ -4896,6 +4900,32 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@langchain/anthropic@^0.3.10": + version "0.3.31" + resolved "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.31.tgz#80bc2464ab98cfb8df0de50cf219d92cfe5934e1" + integrity sha512-XyjwE1mA1I6sirSlVZtI6tyv7nH3+b8F5IFDi9WNKA8+SidJ0o3cP90TxrK7x1sSLmdj+su3f8s2hOusw6xpaw== + dependencies: + "@anthropic-ai/sdk" "^0.65.0" + fast-xml-parser "^4.4.1" + +"@langchain/core@^0.3.28": + version "0.3.78" + resolved "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz#40e69fba6688858edbcab4473358ec7affc685fd" + integrity sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA== + dependencies: + "@cfworker/json-schema" "^4.0.2" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.3.67" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^10.0.0" + zod "^3.25.32" + zod-to-json-schema "^3.22.3" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -8082,6 +8112,33 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@ts-graphviz/adapter@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@ts-graphviz/adapter/-/adapter-2.0.6.tgz#18d5a42304dca7ffff760fcaf311a3148ef4a3bd" + integrity sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/ast@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/ast/-/ast-2.0.7.tgz#4ec33492e4b4e998d4632030e97a9f7e149afb86" + integrity sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/common@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@ts-graphviz/common/-/common-2.1.5.tgz#a256dfaea009a5b147d8f73f25e57fb44f6462a2" + integrity sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg== + +"@ts-graphviz/core@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/core/-/core-2.0.7.tgz#2185e390990038b267a2341c3db1cef3680bbee8" + integrity sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg== + dependencies: + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -9018,6 +9075,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/webidl-conversions@*": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" @@ -9126,11 +9188,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.0.tgz#d725da8dfcff320aab2ac6f65c97b0df30058449" integrity sha512-UTe67B0Ypius0fnEE518NB2N8gGutIlTojeTg4nt0GQvikReVkurqxd2LvYa9q9M5MQ6rtpNyWTBxdscw40Xhw== -"@typescript-eslint/types@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" - integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== - "@typescript-eslint/types@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.4.tgz#5d358484d2be986980c039de68e9f1eb62ea7897" @@ -9167,19 +9224,6 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/typescript-estree@^5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" - integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== - dependencies: - "@typescript-eslint/types" "5.62.0" - "@typescript-eslint/visitor-keys" "5.62.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@^8.23.0": version "8.35.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz#86141e6c55b75bc1eaecc0781bd39704de14e52a" @@ -9231,14 +9275,6 @@ "@typescript-eslint/types" "5.48.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" - integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== - dependencies: - "@typescript-eslint/types" "5.62.0" - eslint-visitor-keys "^3.3.0" - "@typescript-eslint/visitor-keys@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz#80dfecf820fc67574012375859085f91a4dff043" @@ -10853,11 +10889,6 @@ ast-kit@^1.0.1, ast-kit@^1.1.0: "@babel/parser" "^7.25.6" pathe "^1.1.2" -ast-module-types@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-5.0.0.tgz#32b2b05c56067ff38e95df66f11d6afd6c9ba16b" - integrity sha512-JvqziE0Wc0rXQfma0HZC/aY7URXHFuZV84fJRtP8u+lhp0JYCNd5wJzVXP45t0PH0Mej3ynlzvdyITYIu0G4LQ== - ast-module-types@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-6.0.1.tgz#4b4ca0251c57b815bab62604dcb22f8c903e2523" @@ -11426,7 +11457,7 @@ base64-arraybuffer@^1.0.1: resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -12500,16 +12531,16 @@ camelcase@5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== +camelcase@6, camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - camelcase@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" @@ -12584,14 +12615,6 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4. escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@3.0.0, chalk@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" - integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -12611,6 +12634,14 @@ chalk@^1.0.0: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -13232,6 +13263,13 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +console-table-printer@^2.12.1: + version "2.14.6" + resolved "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436" + integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw== + dependencies: + simple-wcswidth "^1.0.1" + console-ui@^3.0.4, console-ui@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/console-ui/-/console-ui-3.1.2.tgz#51aef616ff02013c85ccee6a6d77ef7a94202e7a" @@ -13948,7 +13986,7 @@ decamelize-keys@^1.1.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0: +decamelize@1.2.0, decamelize@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -14184,15 +14222,15 @@ dependency-graph@^0.11.0: resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== -dependency-tree@^10.0.9: - version "10.0.9" - resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-10.0.9.tgz#0c6c0dbeb0c5ec2cf83bf755f30e9cb12e7b4ac7" - integrity sha512-dwc59FRIsht+HfnTVM0BCjJaEWxdq2YAvEDy4/Hn6CwS3CBWMtFnL3aZGAkQn3XCYxk/YcTDE4jX2Q7bFTwCjA== +dependency-tree@^11.0.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-11.2.0.tgz#ae764155b2903267181def4b20be49b1fd76da5e" + integrity sha512-+C1H3mXhcvMCeu5i2Jpg9dc0N29TWTuT6vJD7mHLAfVmAbo9zW8NlkvQ1tYd3PDMab0IRQM0ccoyX68EZtx9xw== dependencies: - commander "^10.0.1" - filing-cabinet "^4.1.6" - precinct "^11.0.5" - typescript "^5.0.4" + commander "^12.1.0" + filing-cabinet "^5.0.3" + precinct "^12.2.0" + typescript "^5.8.3" deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" @@ -14249,16 +14287,6 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detective-amd@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-5.0.2.tgz#579900f301c160efe037a6377ec7e937434b2793" - integrity sha512-XFd/VEQ76HSpym80zxM68ieB77unNuoMwopU2TFT/ErUk5n4KvUTwW4beafAVUugrjV48l4BmmR0rh2MglBaiA== - dependencies: - ast-module-types "^5.0.0" - escodegen "^2.0.0" - get-amd-module-type "^5.0.1" - node-source-walk "^6.0.1" - detective-amd@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-6.0.1.tgz#71eb13b5d9b17222d7b4de3fb89a8e684d8b9a23" @@ -14269,14 +14297,6 @@ detective-amd@^6.0.1: get-amd-module-type "^6.0.1" node-source-walk "^7.0.1" -detective-cjs@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-5.0.1.tgz#836ad51c6de4863efc7c419ec243694f760ff8b2" - integrity sha512-6nTvAZtpomyz/2pmEmGX1sXNjaqgMplhQkskq2MLrar0ZAIkHMrDhLXkRiK2mvbu9wSWr0V5/IfiTrZqAQMrmQ== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.0" - detective-cjs@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-6.0.1.tgz#4fb81a67337630811409abb2148b2b622cacbdcd" @@ -14285,13 +14305,6 @@ detective-cjs@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -detective-es6@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-4.0.1.tgz#38d5d49a6d966e992ef8f2d9bffcfe861a58a88a" - integrity sha512-k3Z5tB4LQ8UVHkuMrFOlvb3GgFWdJ9NqAa2YLUU/jTaWJIm+JJnEh4PsMc+6dfT223Y8ACKOaC0qcj7diIhBKw== - dependencies: - node-source-walk "^6.0.1" - detective-es6@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-5.0.1.tgz#f0c026bc9b767a243e57ef282f4343fcf3b8ec4e" @@ -14299,15 +14312,6 @@ detective-es6@^5.0.1: dependencies: node-source-walk "^7.0.1" -detective-postcss@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-6.1.3.tgz#51a2d4419327ad85d0af071c7054c79fafca7e73" - integrity sha512-7BRVvE5pPEvk2ukUWNQ+H2XOq43xENWbH0LcdCE14mwgTBEAMoAx+Fc1rdp76SmyZ4Sp48HlV7VedUnP6GA1Tw== - dependencies: - is-url "^1.2.4" - postcss "^8.4.23" - postcss-values-parser "^6.0.2" - detective-postcss@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-7.0.1.tgz#f5822d8988339fb56851fcdb079d51fbcff114db" @@ -14316,14 +14320,6 @@ detective-postcss@^7.0.1: is-url "^1.2.4" postcss-values-parser "^6.0.2" -detective-sass@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-5.0.3.tgz#63e54bc9b32f4bdbd9d5002308f9592a3d3a508f" - integrity sha512-YsYT2WuA8YIafp2RVF5CEfGhhyIVdPzlwQgxSjK+TUm3JoHP+Tcorbk3SfG0cNZ7D7+cYWa0ZBcvOaR0O8+LlA== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-sass@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-6.0.1.tgz#fcf5aa51bebf7b721807be418418470ee2409f8a" @@ -14332,14 +14328,6 @@ detective-sass@^6.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-scss@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-4.0.3.tgz#79758baa0158f72bfc4481eb7e21cc3b5f1ea6eb" - integrity sha512-VYI6cHcD0fLokwqqPFFtDQhhSnlFWvU614J42eY6G0s8c+MBhi9QAWycLwIOGxlmD8I/XvGSOUV1kIDhJ70ZPg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" @@ -14348,26 +14336,11 @@ detective-scss@^5.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-stylus@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-4.0.0.tgz#ce97b6499becdc291de7b3c11df8c352c1eee46e" - integrity sha512-TfPotjhszKLgFBzBhTOxNHDsutIxx9GTWjrL5Wh7Qx/ydxKhwUrlSFeLIn+ZaHPF+h0siVBkAQSuy6CADyTxgQ== - detective-stylus@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-5.0.1.tgz#57d54a0b405305ee16655e42008b38a827a9f179" integrity sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA== -detective-typescript@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-11.2.0.tgz#5b1450b518cb84b6cfb98ea72d5edd9660668e1b" - integrity sha512-ARFxjzizOhPqs1fYC/2NMC3N4jrQ6HvVflnXBTRqNEqJuXwyKLRr9CrJwkRcV/SnZt1sNXgsF6FPm0x57Tq0rw== - dependencies: - "@typescript-eslint/typescript-estree" "^5.62.0" - ast-module-types "^5.0.0" - node-source-walk "^6.0.2" - typescript "^5.4.4" - detective-typescript@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-14.0.0.tgz#3cf429652eb7d7d2be2c050ac47af957a559527d" @@ -15324,10 +15297,10 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.0: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -17007,23 +16980,22 @@ filesize@^10.0.5: resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w== -filing-cabinet@^4.1.6: - version "4.2.0" - resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-4.2.0.tgz#bd81241edce6e0c051882bef7b69ffa4c017baf9" - integrity sha512-YZ21ryzRcyqxpyKggdYSoXx//d3sCJzM3lsYoaeg/FyXdADGJrUl+BW1KIglaVLJN5BBcMtWylkygY8zBp2MrQ== +filing-cabinet@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-5.0.3.tgz#e5ab960958653ee7fe70d5d99b3b88c342ce7907" + integrity sha512-PlPcMwVWg60NQkhvfoxZs4wEHjhlOO/y7OAm4sKM60o1Z9nttRY4mcdQxp/iZ+kg/Vv6Hw1OAaTbYVM9DA9pYg== dependencies: app-module-path "^2.2.0" - commander "^10.0.1" - enhanced-resolve "^5.14.1" - is-relative-path "^1.0.2" - module-definition "^5.0.1" - module-lookup-amd "^8.0.5" - resolve "^1.22.3" - resolve-dependency-path "^3.0.2" - sass-lookup "^5.0.1" - stylus-lookup "^5.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" + module-definition "^6.0.1" + module-lookup-amd "^9.0.3" + resolve "^1.22.10" + resolve-dependency-path "^4.0.1" + sass-lookup "^6.1.0" + stylus-lookup "^6.1.0" tsconfig-paths "^4.2.0" - typescript "^5.0.4" + typescript "^5.7.3" fill-range@^4.0.0: version "4.0.0" @@ -17610,14 +17582,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-amd-module-type@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-5.0.1.tgz#bef38ea3674e1aa1bda9c59c8b0da598582f73f2" - integrity sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - get-amd-module-type@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz#191f479ae8706c246b52bf402fbe1bb0965d9f1e" @@ -19689,11 +19653,6 @@ is-regexp@^1.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= -is-relative-path@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-relative-path/-/is-relative-path-1.0.2.tgz#091b46a0d67c1ed0fe85f1f8cfdde006bb251d46" - integrity sha1-CRtGoNZ8HtD+hfH4z93gBrslHUY= - is-set@^2.0.2, is-set@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" @@ -20079,6 +20038,13 @@ js-string-escape@^1.0.1: resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -20483,6 +20449,19 @@ lambda-local@^2.2.0: dotenv "^16.3.1" winston "^3.10.0" +langsmith@^0.3.67: + version "0.3.74" + resolved "https://registry.npmjs.org/langsmith/-/langsmith-0.3.74.tgz#014d31a9ff7530b54f0d797502abd512ce8fb6fb" + integrity sha512-ZuW3Qawz8w88XcuCRH91yTp6lsdGuwzRqZ5J0Hf5q/AjMz7DwcSv0MkE6V5W+8hFMI850QZN2Wlxwm3R9lHlZg== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^4.1.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + p-retry "4" + semver "^7.6.3" + uuid "^10.0.0" + language-subtag-registry@~0.3.2: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -21250,23 +21229,22 @@ lz-string@^1.4.4, lz-string@^1.5.0: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== -madge@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/madge/-/madge-7.0.0.tgz#64b1762033b0f969caa7e5853004b6850e8430bb" - integrity sha512-x9eHkBWoCJ2B8yGesWf8LRucarkbH5P3lazqgvmxe4xn5U2Meyfu906iG9mBB1RnY/f4D+gtELWdiz1k6+jAZA== +madge@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/madge/-/madge-8.0.0.tgz#cca4ab66fb388e7b6bf43c1f78dcaab3cad30f50" + integrity sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw== dependencies: chalk "^4.1.2" commander "^7.2.0" commondir "^1.0.1" debug "^4.3.4" - dependency-tree "^10.0.9" + dependency-tree "^11.0.0" ora "^5.4.1" pluralize "^8.0.0" - precinct "^11.0.5" pretty-ms "^7.0.1" rc "^1.2.8" stream-to-array "^2.3.0" - ts-graphviz "^1.8.1" + ts-graphviz "^2.1.2" walkdir "^0.4.1" magic-regexp@^0.8.0: @@ -22477,14 +22455,6 @@ modify-values@^1.0.1: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -module-definition@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-5.0.1.tgz#62d1194e5d5ea6176b7dc7730f818f466aefa32f" - integrity sha512-kvw3B4G19IXk+BOXnYq/D/VeO9qfHaapMeuS7w7sNUqmGaA6hywdFHMi+VWeR9wUScXM7XjoryTffCZ5B0/8IA== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - module-definition@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-6.0.1.tgz#47e73144cc5a9aa31f3380166fddf8e962ccb2e4" @@ -22498,14 +22468,14 @@ module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== -module-lookup-amd@^8.0.5: - version "8.0.5" - resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-8.0.5.tgz#aaeea41979105b49339380ca3f7d573db78c32a5" - integrity sha512-vc3rYLjDo5Frjox8NZpiyLXsNWJ5BWshztc/5KSOMzpg9k5cHH652YsJ7VKKmtM4SvaxuE9RkrYGhiSjH3Ehow== +module-lookup-amd@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-9.0.5.tgz#2563ba8e4f9dbcda914eac3ba4dc3ad8af80eb7d" + integrity sha512-Rs5FVpVcBYRHPLuhHOjgbRhosaQYLtEo3JIeDIbmNo7mSssi1CTzwMh8v36gAzpbzLGXI9wB/yHh+5+3fY1QVw== dependencies: - commander "^10.0.1" + commander "^12.1.0" glob "^7.2.3" - requirejs "^2.3.6" + requirejs "^2.3.7" requirejs-config-file "^4.0.0" moment@~2.30.1: @@ -23192,13 +23162,6 @@ node-schedule@^2.1.1: long-timeout "0.1.1" sorted-array-functions "^1.3.0" -node-source-walk@^6.0.0, node-source-walk@^6.0.1, node-source-walk@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-6.0.2.tgz#ba81bc4bc0f6f05559b084bea10be84c3f87f211" - integrity sha512-jn9vOIK/nfqoFCcpK89/VCVaLg1IHE6UVfDOzvqmANaJ/rWCTEdH8RZ1V278nv2jr36BJdyQXIAavBLXpzdlag== - dependencies: - "@babel/parser" "^7.21.8" - node-source-walk@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-7.0.1.tgz#3e4ab8d065377228fd038af7b2d4fb58f61defd3" @@ -24173,7 +24136,7 @@ p-pipe@3.1.0: resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== -p-queue@6.6.2: +p-queue@6.6.2, p-queue@^6.6.2: version "6.6.2" resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== @@ -24194,7 +24157,7 @@ p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== -p-retry@^4.5.0: +p-retry@4, p-retry@^4.5.0: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== @@ -25491,7 +25454,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -25550,25 +25513,7 @@ prebuild-install@^7.1.1: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -precinct@^11.0.5: - version "11.0.5" - resolved "https://registry.yarnpkg.com/precinct/-/precinct-11.0.5.tgz#3e15b3486670806f18addb54b8533e23596399ff" - integrity sha512-oHSWLC8cL/0znFhvln26D14KfCQFFn4KOLSw6hmLhd+LQ2SKt9Ljm89but76Pc7flM9Ty1TnXyrA2u16MfRV3w== - dependencies: - "@dependents/detective-less" "^4.1.0" - commander "^10.0.1" - detective-amd "^5.0.2" - detective-cjs "^5.0.1" - detective-es6 "^4.0.1" - detective-postcss "^6.1.3" - detective-sass "^5.0.3" - detective-scss "^4.0.3" - detective-stylus "^4.0.0" - detective-typescript "^11.1.0" - module-definition "^5.0.1" - node-source-walk "^6.0.2" - -precinct@^12.0.0: +precinct@^12.0.0, precinct@^12.2.0: version "12.2.0" resolved "https://registry.yarnpkg.com/precinct/-/precinct-12.2.0.tgz#6ab18f48034cc534f2c8fedb318f19a11bcd171b" integrity sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w== @@ -26728,7 +26673,7 @@ requirejs-config-file@^4.0.0: esprima "^4.0.0" stringify-object "^3.2.1" -requirejs@^2.3.6: +requirejs@^2.3.7: version "2.3.7" resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.7.tgz#0b22032e51a967900e0ae9f32762c23a87036bd0" integrity sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw== @@ -26755,10 +26700,10 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" -resolve-dependency-path@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-3.0.2.tgz#012816717bcbe8b846835da11af9d2beb5acef50" - integrity sha512-Tz7zfjhLfsvR39ADOSk9us4421J/1ztVBo4rWUkF38hgHK5m0OCZ3NxFVpqHRkjctnwVa15igEUHFJp8MCS7vA== +resolve-dependency-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz#1b9d43e5b62384301e26d040b9fce61ee5db60bd" + integrity sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ== resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" @@ -26861,7 +26806,7 @@ resolve@1.22.8: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.3, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -27310,12 +27255,13 @@ sass-loader@13.0.2: klona "^2.0.4" neo-async "^2.6.2" -sass-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-5.0.1.tgz#1f01d7ff21e09d8c9dcf8d05b3fca28f2f96e6ed" - integrity sha512-t0X5PaizPc2H4+rCwszAqHZRtr4bugo4pgiCvrBFvIX0XFxnr29g77LJcpyj9A0DcKf7gXMLcgvRjsonYI6x4g== +sass-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-6.1.0.tgz#a13b1f31dd44d2b4bcd55ba8f72763db4d95bd7c" + integrity sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA== dependencies: - commander "^10.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" sass@1.54.4: version "1.54.4" @@ -27844,6 +27790,11 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" +simple-wcswidth@^1.0.1: + version "1.1.2" + resolved "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" + integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -28777,12 +28728,12 @@ stylus-loader@7.0.0: klona "^2.0.5" normalize-path "^3.0.0" -stylus-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-5.0.1.tgz#3c4d116c3b1e8e1a8169c0d9cd20e608595560f4" - integrity sha512-tLtJEd5AGvnVy4f9UHQMw4bkJJtaAcmo54N+ovQBjDY3DuWyK9Eltxzr5+KG0q4ew6v2EHyuWWNnHeiw/Eo7rQ== +stylus-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-6.1.0.tgz#f0fe88a885b830dc7520f51dd0a7e59e5d3307b4" + integrity sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ== dependencies: - commander "^10.0.1" + commander "^12.1.0" stylus@0.59.0, stylus@^0.59.0: version "0.59.0" @@ -29574,10 +29525,15 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -ts-graphviz@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-1.8.2.tgz#6c4768d05f8a36e37abe34855ffe89a4c4bd96cc" - integrity sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA== +ts-graphviz@^2.1.2: + version "2.1.6" + resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-2.1.6.tgz#007fcb42b4e8c55d26543ece9e86395bd3c3cfd6" + integrity sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw== + dependencies: + "@ts-graphviz/adapter" "^2.0.6" + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@ts-graphviz/core" "^2.0.7" ts-interface-checker@^0.1.9: version "0.1.13" @@ -29811,10 +29767,10 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +"typescript@>=3 < 6", typescript@^5.7.3, typescript@^5.8.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typescript@^3.9: version "3.9.10" @@ -29826,6 +29782,11 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== +typescript@~5.8.0: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" @@ -30555,6 +30516,11 @@ uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" @@ -31970,20 +31936,20 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" -zod-to-json-schema@^3.24.1: - version "3.24.5" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" - integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.24.1: + version "3.24.6" + resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" + integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== zod@3.22.3: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== -zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1: - version "3.25.75" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.75.tgz#8ff9be2fbbcb381a9236f9f74a8879ca29dcc504" - integrity sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg== +zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1, zod@^3.25.32: + version "3.25.76" + resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== zone.js@^0.12.0: version "0.12.0"