diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 64d418c674fb..5e9585d74336 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -25,7 +25,14 @@ "dependencies": { "@anthropic-ai/sdk": "0.63.0", "@apollo/server": "^5.5.0", - "@aws-sdk/client-s3": "^3.1041.0", + "@aws-sdk/client-dynamodb": "^3.1046.0", + "@aws-sdk/client-kinesis": "^3.1046.0", + "@aws-sdk/client-lambda": "^3.1046.0", + "@aws-sdk/client-s3": "^3.1046.0", + "@aws-sdk/client-secrets-manager": "^3.1046.0", + "@aws-sdk/client-sfn": "^3.1046.0", + "@aws-sdk/client-sns": "^3.1046.0", + "@aws-sdk/client-sqs": "^3.1046.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/instrument.mjs new file mode 100644 index 000000000000..fe1c5c47983f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/aws-serverless'; +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, +}); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/scenario.js b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/scenario.js deleted file mode 100644 index 0dc77ebbeb24..000000000000 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/scenario.js +++ /dev/null @@ -1,36 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/aws-serverless'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 1.0, - debug: true, - transport: loggingTransport, -}); - -const { S3 } = require('@aws-sdk/client-s3'); -const nock = require('nock'); - -async function run() { - const bucketName = 'aws-test-bucket'; - const keyName = 'aws-test-object.txt'; - - nock(`https://${bucketName}.s3.amazonaws.com`).get(`/${keyName}`).reply(200, 'contents'); - nock(`https://${bucketName}.s3.amazonaws.com`).put(`/${keyName}`).reply(200, 'contents'); - - await Sentry.startSpan({ name: 'Test Transaction' }, async () => { - const region = 'us-east-1'; - const s3Client = new S3({ region }); - nock(`https://ot-demo-test.s3.${region}.amazonaws.com/`) - .put('/aws-ot-s3-test-object.txt?x-id=PutObject') - .reply(200, 'test'); - - const params = { - Bucket: 'ot-demo-test', - Key: 'aws-ot-s3-test-object.txt', - }; - await s3Client.putObject(params); - }); -} - -run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts deleted file mode 100644 index a6fdc2ce88af..000000000000 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -const EXPECTED_TRANSCATION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'S3.PutObject', - op: 'rpc', - origin: 'auto.otel.aws', - data: expect.objectContaining({ - 'sentry.origin': 'auto.otel.aws', - 'sentry.op': 'rpc', - 'rpc.system': 'aws-api', - 'rpc.method': 'PutObject', - 'rpc.service': 'S3', - 'cloud.region': 'us-east-1', - 'aws.s3.bucket': 'ot-demo-test', - 'otel.kind': 'CLIENT', - }), - }), - ]), -}; - -describe('awsIntegration', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - test('should auto-instrument aws-sdk v2 package.', async () => { - await createRunner(__dirname, 'scenario.js') - .ignore('event') - .expect({ transaction: EXPECTED_TRANSCATION }) - .start() - .completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/scenario.mjs new file mode 100644 index 000000000000..0e58050adc9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/scenario.mjs @@ -0,0 +1,168 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { createHash } from 'crypto'; +import { DynamoDBClient, PutItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { KinesisClient, PutRecordCommand } from '@aws-sdk/client-kinesis'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { S3 } from '@aws-sdk/client-s3'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { SFNClient, StartExecutionCommand } from '@aws-sdk/client-sfn'; +import { PublishCommand, SNSClient } from '@aws-sdk/client-sns'; +import { ReceiveMessageCommand, SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs'; +// The Kinesis client defaults to an HTTP/2 request handler, which `nock` cannot intercept. +// Force the HTTP/1 handler so the request is mocked instead of hitting real AWS. +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import nock from 'nock'; + +nock.disableNetConnect(); + +const region = 'us-east-1'; +const credentials = { accessKeyId: 'aws-test-key', secretAccessKey: 'aws-test-secret' }; + +async function s3() { + const s3Client = new S3({ region, credentials, maxAttempts: 1 }); + const host = `https://ot-demo-test.s3.${region}.amazonaws.com`; + + nock(host).put('/aws-ot-s3-test-object.txt?x-id=PutObject').reply(200, 'test'); + await s3Client.putObject({ Bucket: 'ot-demo-test', Key: 'aws-ot-s3-test-object.txt' }); + + nock(host).get('/aws-ot-s3-test-object.txt?x-id=GetObject').reply(200, 'contents'); + const getResult = await s3Client.getObject({ Bucket: 'ot-demo-test', Key: 'aws-ot-s3-test-object.txt' }); + await getResult.Body?.transformToString(); + + nock(host) + .get('/missing-object.txt?x-id=GetObject') + .reply( + 404, + 'NoSuchKeyThe specified key does not exist.', + { 'content-type': 'application/xml' }, + ); + try { + await s3Client.getObject({ Bucket: 'ot-demo-test', Key: 'missing-object.txt' }); + } catch { + // expected + } +} + +async function dynamodb() { + const client = new DynamoDBClient({ region, credentials, maxAttempts: 1 }); + + nock(`https://dynamodb.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({}), { 'content-type': 'application/x-amz-json-1.0' }); + await client.send(new PutItemCommand({ TableName: 'my-table', Item: { id: { S: 'some-id' } } })); + + nock(`https://dynamodb.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({ Items: [{ id: { S: 'some-id' } }], Count: 1, ScannedCount: 1 }), { + 'content-type': 'application/x-amz-json-1.0', + }); + await client.send( + new QueryCommand({ + TableName: 'my-table', + KeyConditionExpression: 'id = :id', + ExpressionAttributeValues: { ':id': { S: 'some-id' } }, + }), + ); +} + +async function sqs() { + const client = new SQSClient({ region, credentials, maxAttempts: 1 }); + const queueUrl = `https://sqs.${region}.amazonaws.com/123456789012/my-queue`; + const messageBody = 'Hello from Sentry'; + const md5 = createHash('md5').update(messageBody).digest('hex'); + + nock(`https://sqs.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({ MessageId: 'message-id-1', MD5OfMessageBody: md5 }), { + 'content-type': 'application/x-amz-json-1.0', + }); + await client.send(new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: messageBody })); + + nock(`https://sqs.${region}.amazonaws.com`) + .post('/') + .reply( + 200, + JSON.stringify({ + Messages: [{ MessageId: 'message-id-2', Body: messageBody, MD5OfBody: md5, ReceiptHandle: 'handle' }], + }), + { 'content-type': 'application/x-amz-json-1.0' }, + ); + await client.send(new ReceiveMessageCommand({ QueueUrl: queueUrl })); +} + +async function sns() { + const client = new SNSClient({ region, credentials, maxAttempts: 1 }); + + nock(`https://sns.${region}.amazonaws.com`) + .post('/') + .reply( + 200, + 'message-id-1request-id-1', + { 'content-type': 'text/xml' }, + ); + await client.send(new PublishCommand({ TopicArn: 'arn:aws:sns:us-east-1:123456789012:my-topic', Message: 'Hello' })); +} + +async function lambda() { + const client = new LambdaClient({ region, credentials, maxAttempts: 1 }); + + nock(`https://lambda.${region}.amazonaws.com`) + .post('/2015-03-31/functions/my-function/invocations') + .reply(200, JSON.stringify({ result: 'ok' }), { + 'content-type': 'application/json', + 'x-amzn-requestid': 'request-id-1', + }); + await client.send(new InvokeCommand({ FunctionName: 'my-function' })); +} + +async function kinesis() { + const client = new KinesisClient({ region, credentials, maxAttempts: 1, requestHandler: new NodeHttpHandler() }); + + nock(`https://kinesis.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({ SequenceNumber: '1', ShardId: 'shardId-000000000000' }), { + 'content-type': 'application/x-amz-json-1.1', + }); + await client.send( + new PutRecordCommand({ StreamName: 'my-stream', Data: Buffer.from('data'), PartitionKey: 'partition-key' }), + ); +} + +async function secretsmanager() { + const client = new SecretsManagerClient({ region, credentials, maxAttempts: 1 }); + const secretArn = 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret-abc'; + + nock(`https://secretsmanager.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({ ARN: secretArn, Name: 'my-secret', SecretString: 'secret-value' }), { + 'content-type': 'application/x-amz-json-1.1', + }); + await client.send(new GetSecretValueCommand({ SecretId: secretArn })); +} + +async function stepfunctions() { + const client = new SFNClient({ region, credentials, maxAttempts: 1 }); + const stateMachineArn = 'arn:aws:states:us-east-1:123456789012:stateMachine:my-state-machine'; + + nock(`https://states.${region}.amazonaws.com`) + .post('/') + .reply(200, JSON.stringify({ executionArn: `${stateMachineArn}:execution-1`, startDate: 1 }), { + 'content-type': 'application/x-amz-json-1.0', + }); + await client.send(new StartExecutionCommand({ stateMachineArn, input: '{}' })); +} + +async function run() { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await s3(); + await dynamodb(); + await sqs(); + await sns(); + await lambda(); + await kinesis(); + await secretsmanager(); + await stepfunctions(); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts new file mode 100644 index 000000000000..a27b5c1a88ff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -0,0 +1,222 @@ +import type { TransactionEvent } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The +// patch target differs between aws-sdk versions, so we run the exact same assertions against both: +// - the current aws-sdk (default, resolved from the workspace) which routes through `@smithy/core` >= 3.24.0 +// - aws-sdk 3.1041.0 pinned together with the pre-`@smithy/core` stack (`@smithy/middleware-stack`) +const LEGACY_AWS_SDK_DEPENDENCIES = { + '@aws-sdk/client-dynamodb': '3.1041.0', + '@aws-sdk/client-kinesis': '3.1041.0', + '@aws-sdk/client-lambda': '3.1041.0', + '@aws-sdk/client-s3': '3.1041.0', + '@aws-sdk/client-secrets-manager': '3.1041.0', + '@aws-sdk/client-sfn': '3.1041.0', + '@aws-sdk/client-sns': '3.1041.0', + '@aws-sdk/client-sqs': '3.1041.0', + // Pin the smithy layer to the pre-`@smithy/core` versions, otherwise the 3.1041.0 clients still + // resolve `@smithy/smithy-client` >= 4.13 (which routes through `@smithy/core` >= 3.24.0). + '@smithy/smithy-client': '4.12.13', + '@smithy/core': '3.23.17', + '@smithy/middleware-stack': '4.2.14', + '@smithy/node-http-handler': '4.7.8', +}; + +/** + * Asserts the transaction contains one span per instrumented aws-sdk service. Each service is checked + * with its own `expect` so a failure points at the specific service rather than the whole transaction. + */ +function assertAwsServiceSpans(transaction: TransactionEvent): void { + const spans = transaction.spans ?? []; + + const expectSpan = (label: string, expected: Record): void => { + expect(spans, `expected an aws-sdk span for "${label}"`).toContainEqual(expect.objectContaining(expected)); + }; + + expect(transaction.transaction).toBe('Test Transaction'); + + // S3 - PutObject (success) + expectSpan('S3.PutObject', { + description: 'S3.PutObject', + op: 'rpc', + origin: 'auto.otel.aws', + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': 'auto.otel.aws', + 'sentry.op': 'rpc', + 'rpc.system': 'aws-api', + 'rpc.method': 'PutObject', + 'rpc.service': 'S3', + 'cloud.region': 'us-east-1', + 'aws.s3.bucket': 'ot-demo-test', + 'otel.kind': 'CLIENT', + }), + }); + + // S3 - GetObject (success) + expectSpan('S3.GetObject (success)', { + description: 'S3.GetObject', + op: 'rpc', + origin: 'auto.otel.aws', + status: 'ok', + data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3', 'aws.s3.bucket': 'ot-demo-test' }), + }); + + // S3 - GetObject (errored, missing key) + expectSpan('S3.GetObject (error)', { + description: 'S3.GetObject', + op: 'rpc', + origin: 'auto.otel.aws', + status: 'internal_error', + data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3' }), + }); + + // DynamoDB - PutItem + expectSpan('DynamoDB.PutItem', { + description: 'DynamoDB.PutItem', + op: 'db', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'sentry.op': 'db', + 'rpc.method': 'PutItem', + 'rpc.service': 'DynamoDB', + 'db.system': 'dynamodb', + 'db.name': 'my-table', + 'db.operation': 'PutItem', + 'aws.dynamodb.table_names': ['my-table'], + }), + }); + + // DynamoDB - Query + expectSpan('DynamoDB.Query', { + description: 'DynamoDB.Query', + op: 'db', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'Query', + 'db.operation': 'Query', + 'aws.dynamodb.count': 1, + 'aws.dynamodb.scanned_count': 1, + }), + }); + + // SQS - SendMessage (producer) + expectSpan('SQS SendMessage', { + description: 'my-queue send', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'SendMessage', + 'rpc.service': 'SQS', + 'messaging.system': 'aws_sqs', + 'messaging.destination.name': 'my-queue', + 'url.full': 'https://sqs.us-east-1.amazonaws.com/123456789012/my-queue', + 'messaging.message.id': 'message-id-1', + 'otel.kind': 'PRODUCER', + }), + }); + + // SQS - ReceiveMessage (consumer) + expectSpan('SQS ReceiveMessage', { + description: 'my-queue receive', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'ReceiveMessage', + 'messaging.system': 'aws_sqs', + 'messaging.operation.type': 'receive', + 'messaging.batch.message_count': 1, + 'otel.kind': 'CONSUMER', + }), + }); + + // SNS - Publish (producer) + expectSpan('SNS Publish', { + description: 'my-topic send', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'Publish', + 'rpc.service': 'SNS', + 'messaging.system': 'aws.sns', + 'messaging.destination': 'my-topic', + 'aws.sns.topic.arn': 'arn:aws:sns:us-east-1:123456789012:my-topic', + 'otel.kind': 'PRODUCER', + }), + }); + + // Lambda - Invoke + expectSpan('Lambda Invoke', { + description: 'my-function Invoke', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'Invoke', + 'rpc.service': 'Lambda', + 'faas.invoked_name': 'my-function', + 'faas.invoked_provider': 'aws', + 'faas.execution': 'request-id-1', + }), + }); + + // Kinesis - PutRecord + expectSpan('Kinesis.PutRecord', { + description: 'Kinesis.PutRecord', + op: 'rpc', + origin: 'auto.otel.aws', + status: 'ok', + data: expect.objectContaining({ + 'rpc.method': 'PutRecord', + 'rpc.service': 'Kinesis', + 'aws.kinesis.stream.name': 'my-stream', + }), + }); + + // SecretsManager - GetSecretValue + expectSpan('SecretsManager.GetSecretValue', { + description: 'SecretsManager.GetSecretValue', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'GetSecretValue', + 'rpc.service': 'SecretsManager', + 'aws.secretsmanager.secret.arn': 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret-abc', + }), + }); + + // StepFunctions - StartExecution + expectSpan('StepFunctions.StartExecution', { + description: 'SFN.StartExecution', + op: 'rpc', + origin: 'auto.otel.aws', + data: expect.objectContaining({ + 'rpc.method': 'StartExecution', + 'rpc.service': 'SFN', + 'aws.step_functions.state_machine.arn': 'arn:aws:states:us-east-1:123456789012:stateMachine:my-state-machine', + }), + }); +} + +describe('awsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe.each([ + { label: 'latest', additionalDependencies: undefined }, + { label: 'v3.1041.0 (@smithy/middleware-stack)', additionalDependencies: LEGACY_AWS_SDK_DEPENDENCIES }, + ])('aws-sdk $label', ({ additionalDependencies }) => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments aws-sdk service operations', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ transaction: assertAwsServiceSpans }).start().completed(); + }); + }, + { additionalDependencies }, + ); + }); +}); diff --git a/packages/aws-serverless/src/integration/aws/vendored/aws-sdk.ts b/packages/aws-serverless/src/integration/aws/vendored/aws-sdk.ts index b20c1387d72a..2864f202c9ac 100644 --- a/packages/aws-serverless/src/integration/aws/vendored/aws-sdk.ts +++ b/packages/aws-serverless/src/integration/aws/vendored/aws-sdk.ts @@ -16,6 +16,8 @@ * NOTICE from the Sentry authors: * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-aws-sdk * - Upstream version: @opentelemetry/instrumentation-aws-sdk@0.73.0 + * - Backported the `@smithy/core` >= 3.24.0 support from upstream 0.74.0 + * (https://github.com/open-telemetry/opentelemetry-js-contrib/pull/3530) */ /* eslint-disable */ @@ -121,6 +123,20 @@ export class AwsInstrumentation extends InstrumentationBase= 3.24.0: the `Client` class moved from @smithy/smithy-client + // into the @smithy/core/client bundle, and `constructStack` became a closed-over local in + // that bundle (so wrapping the @smithy/middleware-stack export no longer applies). We patch + // `Client.prototype.send` there, and patch the live middleware stack from within `send`. + const v3SmithyCoreClientFile = new InstrumentationNodeModuleFile( + '@smithy/core/dist-cjs/submodules/client/index.js', + ['>=3.24.0'], + this.patchV3SmithyClient.bind(this), + this.unpatchV3SmithyClient.bind(this), + ); + const v3SmithyCore = new InstrumentationNodeModuleDefinition('@smithy/core', ['>=3.24.0'], undefined, undefined, [ + v3SmithyCoreClientFile, + ]); + const v3SmithyClient = new InstrumentationNodeModuleDefinition( '@aws-sdk/smithy-client', ['^3.1.0'], @@ -136,7 +152,7 @@ export class AwsInstrumentation extends InstrumentationBase Promise) { + private _getV3SmithyClientSendPatch( + moduleVersion: string | undefined, + original: (...args: unknown[]) => Promise, + ) { + const self = this; return function send(this: any, command: V3PluginCommand, ...args: unknown[]): Promise { command[V3_CLIENT_CONFIG_KEY] = this.config; + // For @smithy/core >= 3.24.0 `constructStack` is no longer patchable via its export, so we + // patch the live middleware stack instance here instead. This is a no-op (guarded by + // `isWrapped`) for older versions where the stack was already patched via `constructStack`. + self.patchV3MiddlewareStack(moduleVersion, this.middlewareStack); return original.apply(this, [command, ...args]); }; } @@ -247,8 +271,12 @@ export class AwsInstrumentation extends InstrumentationBase