From 86e221a8d4d2905eca4c126ce9b24904a495250e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 13:04:33 +0200 Subject: [PATCH 1/2] feat(server-utils): Migrate `@opentelemetry/instrumentation-aws-sdk` to orchestrion Migrates the aws-sdk (v3) instrumentation from the OTel `InstrumentationBase` patcher (vendored in `@sentry/aws-serverless`) to an orchestrion diagnostics-channel listener. The channel-based integration subscribes to `orchestrion::send` (injected into the smithy `Client.prototype.send`) and emits the same spans as the OTel integration for all services (S3, DynamoDB, SQS, SNS, Lambda, Kinesis, SecretsManager, StepFunctions, Bedrock) with a distinct `auto.aws.orchestrion.aws-sdk` origin. Registered in `channelIntegrations` so it is reachable via `@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`, and swapped in for the OTel `Aws` integration in `@sentry/aws-serverless` under the same opt-in. closes https://github.com/getsentry/sentry-javascript/issues/20946 Co-Authored-By: Claude Opus 4.8 --- .oxlintrc.base.json | 11 + .../scripts/consistentExports.ts | 3 + .../aws-integration-streamed/test.ts | 7 +- .../aws-serverless/aws-integration/test.ts | 32 +- packages/aws-serverless/src/index.ts | 2 + packages/aws-serverless/src/init.ts | 13 +- packages/node/src/index.ts | 1 + packages/node/src/sdk/index.ts | 27 +- .../tracing-channel/aws-sdk/aws-sdk.types.ts | 59 +++ .../tracing-channel/aws-sdk/constants.ts | 65 +++ .../tracing-channel/aws-sdk/index.ts | 205 ++++++++ .../aws-sdk/services/MessageAttributes.ts | 83 +++ .../aws-sdk/services/ServiceExtension.ts | 16 + .../aws-sdk/services/ServicesExtensions.ts | 47 ++ .../aws-sdk/services/bedrock-runtime.ts | 480 ++++++++++++++++++ .../aws-sdk/services/dynamodb.ts | 184 +++++++ .../tracing-channel/aws-sdk/services/index.ts | 1 + .../aws-sdk/services/kinesis.ts | 20 + .../aws-sdk/services/lambda.ts | 86 ++++ .../tracing-channel/aws-sdk/services/s3.ts | 20 + .../aws-sdk/services/secretsmanager.ts | 27 + .../tracing-channel/aws-sdk/services/sns.ts | 74 +++ .../tracing-channel/aws-sdk/services/sqs.ts | 136 +++++ .../aws-sdk/services/stepfunctions.ts | 25 + .../tracing-channel/aws-sdk/types.ts | 31 ++ .../tracing-channel/aws-sdk/utils.ts | 31 ++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/aws-sdk.ts | 34 ++ .../src/orchestrion/config/index.ts | 2 + .../server-utils/src/orchestrion/index.ts | 3 + 30 files changed, 1701 insertions(+), 26 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts create mode 100644 packages/server-utils/src/orchestrion/config/aws-sdk.ts diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index dd6ed529bc95..aba1665cd578 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -147,6 +147,17 @@ "no-param-reassign": "off" } }, + { + "files": ["**/integrations/tracing-channel/aws-sdk/**/*.ts"], + "rules": { + "typescript/no-unsafe-member-access": "off", + "typescript/no-explicit-any": "off", + "typescript/no-this-alias": "off", + "max-lines": "off", + "complexity": "off", + "no-param-reassign": "off" + } + }, { "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], "rules": { diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index dd6f7f4613cb..f88fa8773167 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -29,6 +29,9 @@ const NODE_EXPORTS_IGNORE = [ // factories for that same Node-runtime-only opt-in, so it isn't surfaced // through the framework / serverless SDKs either. 'diagnosticsChannelInjectionIntegrations', + // Helper for SDKs that build their own default-integration set (e.g. aws-serverless) + // to apply the diagnostics-channel integration swap; not surfaced elsewhere. + 'applyDiagnosticsChannelInjectionIntegrations', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts index 7491894494eb..e8a08726b011 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts @@ -1,7 +1,12 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// See the non-streamed `aws-integration` suite: only the origin differs between the OTel and +// orchestrion diagnostics-channel runs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws-sdk' : 'auto.otel.aws'; + // 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 @@ -49,7 +54,7 @@ function assertAwsServiceSpans(spanCcontainer: SerializedStreamedSpanContainer): name: 'S3.PutObject', status: 'ok', attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.otel.aws', type: 'string' }, + 'sentry.origin': { value: ORIGIN, type: 'string' }, 'sentry.op': { value: 'rpc', type: 'string' }, 'rpc.system': { value: 'aws-api', type: 'string' }, 'rpc.method': { value: 'PutObject', type: 'string' }, 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 index a27b5c1a88ff..c20bb5962fb7 100644 --- 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 @@ -1,7 +1,13 @@ import type { TransactionEvent } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws-sdk' : 'auto.otel.aws'; + // 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 @@ -40,10 +46,10 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.PutObject', { description: 'S3.PutObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ - 'sentry.origin': 'auto.otel.aws', + 'sentry.origin': ORIGIN, 'sentry.op': 'rpc', 'rpc.system': 'aws-api', 'rpc.method': 'PutObject', @@ -58,7 +64,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (success)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3', 'aws.s3.bucket': 'ot-demo-test' }), }); @@ -67,7 +73,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (error)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'internal_error', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3' }), }); @@ -76,7 +82,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.PutItem', { description: 'DynamoDB.PutItem', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'sentry.op': 'db', 'rpc.method': 'PutItem', @@ -92,7 +98,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.Query', { description: 'DynamoDB.Query', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Query', 'db.operation': 'Query', @@ -105,7 +111,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS SendMessage', { description: 'my-queue send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'SendMessage', 'rpc.service': 'SQS', @@ -121,7 +127,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS ReceiveMessage', { description: 'my-queue receive', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'ReceiveMessage', 'messaging.system': 'aws_sqs', @@ -135,7 +141,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SNS Publish', { description: 'my-topic send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Publish', 'rpc.service': 'SNS', @@ -150,7 +156,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Lambda Invoke', { description: 'my-function Invoke', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Invoke', 'rpc.service': 'Lambda', @@ -164,7 +170,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Kinesis.PutRecord', { description: 'Kinesis.PutRecord', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'PutRecord', @@ -177,7 +183,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SecretsManager.GetSecretValue', { description: 'SecretsManager.GetSecretValue', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'GetSecretValue', 'rpc.service': 'SecretsManager', @@ -189,7 +195,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('StepFunctions.StartExecution', { description: 'SFN.StartExecution', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'StartExecution', 'rpc.service': 'SFN', diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 71222eb0a6a6..2078044d64a8 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -167,6 +167,8 @@ export { metrics, spanStreamingIntegration, withStreamedSpan, + experimentalUseDiagnosticsChannelInjection, + diagnosticsChannelInjectionIntegrations, } from '@sentry/node'; export { diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index fe069d7ff6f5..afdbf3a6c1dd 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,7 +1,11 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, getSDKSource } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { + applyDiagnosticsChannelInjectionIntegrations, + getDefaultIntegrationsWithoutPerformance, + initWithoutDefaultIntegrations, +} from '@sentry/node'; import { envToBool } from '@sentry/node-core'; import { DEBUG_BUILD } from './debug-build'; import { awsIntegration } from './integration/aws'; @@ -49,8 +53,11 @@ function shouldDisableLayerExtensionForProxy(): boolean { */ // NOTE: in awslambda-auto.ts, we also call the original `getDefaultIntegrations` from `@sentry/node` to load performance integrations. // If at some point we need to filter a node integration out for good, we need to make sure to also filter it out there. -export function getDefaultIntegrations(_options: Options): Integration[] { - return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; +export function getDefaultIntegrations(options: Options): Integration[] { + const integrations = [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; + // If the app opted into diagnostics-channel injection, swap the OTel `Aws` integration for its + // channel-based equivalent (registered in `@sentry/server-utils`). No-op otherwise. + return applyDiagnosticsChannelInjectionIntegrations(integrations, options); } export interface AwsServerlessOptions extends NodeOptions { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a03f9e239525..5d7b94ca41d5 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -46,6 +46,7 @@ export { getDefaultIntegrations, getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, + applyDiagnosticsChannelInjectionIntegrations, } from './sdk'; export { experimentalUseDiagnosticsChannelInjection, diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 8c8d2e887541..5c89737d9166 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -39,15 +39,24 @@ export function getDefaultIntegrations(options: Options): Integration[] { ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - // When the app opted into diagnostics-channel injection (via - // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is - // enabled, swap the channel-based integrations in place of OTel equivalents - // so the two don't both instrument the same library. - // - // Every channel-based integration we ship today is a 1:1 replacement for an - // OTel performance/tracing integration and produces nothing but spans (those - // only come from `getAutoPerformanceIntegrations()` above), so it's gated on - // span recording. + return applyDiagnosticsChannelInjectionIntegrations(integrations, options); +} + +/** + * When the app opted into diagnostics-channel injection (via + * `experimentalUseDiagnosticsChannelInjection()`) AND span recording is enabled, swap the + * channel-based integrations in place of their OTel equivalents so the two don't both instrument the + * same library. Otherwise returns `integrations` unchanged. + * + * Every channel-based integration we ship today is a 1:1 replacement for an OTel performance/tracing + * integration and produces nothing but spans, so this is gated on span recording. Exported so SDKs + * that build their own default-integration set (e.g. `@sentry/aws-serverless`) can apply the same + * swap instead of duplicating this logic. + */ +export function applyDiagnosticsChannelInjectionIntegrations( + integrations: Integration[], + options: Options, +): Integration[] { if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); if (diagnosticsChannelInjection) { diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts new file mode 100644 index 000000000000..bf290fc443b7 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts @@ -0,0 +1,59 @@ +/* + * AWS SDK for JavaScript + * Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * This product includes software developed at + * Amazon Web Services, Inc. (http://aws.amazon.com/). + */ + +/* + These are slightly modified and simplified versions of the actual SQS/SNS types included + in the official distribution: + https://github.com/aws/aws-sdk-js/blob/master/clients/sqs.d.ts + These are brought here to avoid having users install the `aws-sdk` whenever they + require this instrumentation. +*/ + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface Blob {} +type Binary = Buffer | Uint8Array | Blob | string; + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SNS { + interface MessageAttributeValue { + DataType: string; + StringValue?: string; + BinaryValue?: Binary; + } + + export type MessageAttributeMap = { [key: string]: MessageAttributeValue }; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SQS { + type StringList = string[]; + type BinaryList = Binary[]; + interface MessageAttributeValue { + StringValue?: string; + BinaryValue?: Binary; + StringListValues?: StringList; + BinaryListValues?: BinaryList; + DataType: string; + } + + export type MessageBodyAttributeMap = { + [key: string]: MessageAttributeValue; + }; + + type MessageSystemAttributeMap = { [key: string]: string }; + + export interface Message { + MessageId?: string; + ReceiptHandle?: string; + MD5OfBody?: string; + Body?: string; + Attributes?: MessageSystemAttributeMap; + MD5OfMessageAttributes?: string; + MessageAttributes?: MessageBodyAttributeMap; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts new file mode 100644 index 000000000000..7cb0df0ea392 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -0,0 +1,65 @@ +/** + * AWS-specific span attribute names used by the aws-sdk channel integration. + * + * These mirror the constants the OTel `@opentelemetry/instrumentation-aws-sdk` emits (some are + * unstable/obsolete OTel semantic conventions with no `ATTR_*` export in + * `@opentelemetry/semantic-conventions`), inlined here so the integration stays free of OTel deps. + * Standard conventions (`rpc.*`, `db.*`, `messaging.*`, `gen_ai.*`, …) come from + * `@sentry/conventions/attributes` directly. + */ + +/** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */ +export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws-sdk'; + +export const ATTR_RPC_SYSTEM = 'rpc.system'; +export const CLOUD_REGION = 'cloud.region'; +export const AWS_REQUEST_ID = 'aws.request.id'; +export const AWS_REQUEST_EXTENDED_ID = 'aws.request.extended_id'; +export const AWS_S3_BUCKET = 'aws.s3.bucket'; +export const AWS_KINESIS_STREAM_NAME = 'aws.kinesis.stream.name'; + +// DynamoDB +export const ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS = 'aws.dynamodb.attribute_definitions'; +export const ATTR_AWS_DYNAMODB_CONSISTENT_READ = 'aws.dynamodb.consistent_read'; +export const ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY = 'aws.dynamodb.consumed_capacity'; +export const ATTR_AWS_DYNAMODB_COUNT = 'aws.dynamodb.count'; +export const ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE = 'aws.dynamodb.exclusive_start_table'; +export const ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES = 'aws.dynamodb.global_secondary_indexes'; +export const ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES = 'aws.dynamodb.global_secondary_index_updates'; +export const ATTR_AWS_DYNAMODB_INDEX_NAME = 'aws.dynamodb.index_name'; +export const ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS = 'aws.dynamodb.item_collection_metrics'; +export const ATTR_AWS_DYNAMODB_LIMIT = 'aws.dynamodb.limit'; +export const ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES = 'aws.dynamodb.local_secondary_indexes'; +export const ATTR_AWS_DYNAMODB_PROJECTION = 'aws.dynamodb.projection'; +export const ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY = 'aws.dynamodb.provisioned_read_capacity'; +export const ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY = 'aws.dynamodb.provisioned_write_capacity'; +export const ATTR_AWS_DYNAMODB_SCANNED_COUNT = 'aws.dynamodb.scanned_count'; +export const ATTR_AWS_DYNAMODB_SCAN_FORWARD = 'aws.dynamodb.scan_forward'; +export const ATTR_AWS_DYNAMODB_SEGMENT = 'aws.dynamodb.segment'; +export const ATTR_AWS_DYNAMODB_SELECT = 'aws.dynamodb.select'; +export const ATTR_AWS_DYNAMODB_TABLE_COUNT = 'aws.dynamodb.table_count'; +export const ATTR_AWS_DYNAMODB_TABLE_NAMES = 'aws.dynamodb.table_names'; +export const ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS = 'aws.dynamodb.total_segments'; +export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; + +// SecretsManager / SNS / StepFunctions +export const ATTR_AWS_SECRETSMANAGER_SECRET_ARN = 'aws.secretsmanager.secret.arn'; +export const ATTR_AWS_SNS_TOPIC_ARN = 'aws.sns.topic.arn'; +export const ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN = 'aws.step_functions.activity.arn'; +export const ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN = 'aws.step_functions.state_machine.arn'; + +// Lambda (faas) +export const ATTR_FAAS_INVOKED_NAME = 'faas.invoked_name'; +export const ATTR_FAAS_INVOKED_PROVIDER = 'faas.invoked_provider'; +export const ATTR_FAAS_INVOKED_REGION = 'faas.invoked_region'; +export const ATTR_FAAS_EXECUTION = 'faas.execution'; + +// Messaging (obsolete OTel conventions kept for parity with the OTel integration) +export const ATTR_MESSAGING_DESTINATION = 'messaging.destination'; +export const ATTR_MESSAGING_DESTINATION_KIND = 'messaging.destination_kind'; +export const MESSAGING_DESTINATION_KIND_VALUE_TOPIC = 'topic'; + +// Bedrock (gen_ai) +export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES = 'gen_ai.request.stop_sequences'; +export const GEN_AI_OPERATION_NAME_VALUE_CHAT = 'chat'; +export const GEN_AI_SYSTEM_VALUE_AWS_BEDROCK = 'aws.bedrock'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts new file mode 100644 index 000000000000..bc862a96bc29 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -0,0 +1,205 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { HTTP_STATUS_CODE } from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import type { TracingChannelLifeCycleOptions } from '../../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { AWS_REQUEST_EXTENDED_ID, AWS_REQUEST_ID, AWS_SDK_ORIGIN, CLOUD_REGION } from './constants'; +import { ServicesExtensions } from './services'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; +import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; + +// Same name as the OTel `Aws` integration by design, so enabling injection swaps this in for it. +const INTEGRATION_NAME = 'Aws' as const; + +// The context orchestrion's transform attaches to the channel: `arguments` is the live args of the +// wrapped `Client.prototype.send` call (`[command, ...]`), `self` the client, `result`/`error` the +// settled value. The `_sentry*` fields are stashed by us across the call's lifecycle. +interface AwsSendChannelContext { + arguments: unknown[]; + self?: { config?: AwsClientConfig; constructor?: { name?: string } }; + result?: unknown; + error?: unknown; + _sentryNormalizedRequest?: NormalizedRequest; + _sentryRequestMetadata?: RequestMetadata; +} + +interface AwsClientConfig { + serviceId?: string; + region?: () => string | Promise | undefined; +} + +interface AwsV3Command { + input?: Record; + constructor?: { name?: string }; +} + +/** Runs a span-building callback so a throw inside it can never break the user's aws-sdk call. */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error building span', error); + return undefined; + } +} + +function setMetadataAttributes(span: Span, metadata: Record | undefined): void { + if (!metadata) { + return; + } + if (metadata.requestId) { + span.setAttribute(AWS_REQUEST_ID, metadata.requestId); + } + if (metadata.httpStatusCode) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_STATUS_CODE, metadata.httpStatusCode); + } + if (metadata.extendedRequestId) { + span.setAttribute(AWS_REQUEST_EXTENDED_ID, metadata.extendedRequestId); + } +} + +const _awsChannelIntegration = (() => { + const servicesExtensions = new ServicesExtensions(); + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + const getSpan = (data: AwsSendChannelContext): Span | undefined => + safe(() => { + const command = data.arguments[0] as AwsV3Command | undefined; + const commandInput = command?.input; + const commandName = command?.constructor?.name; + if (!command || !commandName || !commandInput) { + // Not a recognizable v3 command call; leave the active context untouched. + return undefined; + } + + const clientConfig = data.self?.config; + const serviceName = + clientConfig?.serviceId ?? + // `clientName` isn't available at the `send` boundary; fall back to the client's + // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. + removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); + + const normalizedRequest = normalizeV3Request(serviceName, commandName, commandInput, undefined); + const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + + const span = startInactiveSpan({ + name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, + kind: requestMetadata.spanKind ?? SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, + ...extractAttributesFromNormalizedRequest(normalizedRequest), + ...requestMetadata.spanAttributes, + }, + }); + + data._sentryNormalizedRequest = normalizedRequest; + data._sentryRequestMetadata = requestMetadata; + + // `region` resolves asynchronously; set it on the span (still open until `send` settles) + // and backfill it on the normalized request once available. + Promise.resolve(clientConfig?.region?.()) + .then(region => { + if (region) { + normalizedRequest.region = region; + span.setAttribute(CLOUD_REGION, region); + } + }) + .catch(() => { + // Nothing to do; continue without a region. + }); + + // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before + // `send` proceeds, so the mutated `commandInput` is used to build the request. + safe(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); + + return span; + }); + + const opts: TracingChannelLifeCycleOptions = { + deferSpanEnd({ span, data }) { + const normalizedRequest = data._sentryNormalizedRequest; + const requestMetadata = data._sentryRequestMetadata; + if (!normalizedRequest) { + return false; + } + + const failed = 'error' in data; + + safe(() => { + if (failed) { + const err = data.error as { $metadata?: Record; RequestId?: string } | undefined; + setMetadataAttributes(span, { requestId: err?.RequestId, ...err?.$metadata }); + return; + } + + const output = data.result as { $metadata?: Record } | undefined; + setMetadataAttributes(span, output?.$metadata); + + const normalizedResponse: NormalizedResponse = { + data: output, + request: normalizedRequest, + requestId: output?.$metadata?.requestId, + }; + servicesExtensions.responseHook(normalizedResponse, span); + }); + + // Streaming responses end the span when their wrapped stream is consumed (see + // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. + return !!requestMetadata?.isStream && !failed; + }, + }; + + // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the + // transform injects one channel per package. Only the package hosting the app's client fires, so + // subscribing to all of them is safe and never double-instruments a single call. + const awsSendChannels = [ + CHANNELS.AWS_SMITHY_CORE_SEND, + CHANNELS.AWS_SMITHY_CLIENT_SEND, + CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, + ] as const; + + DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); + + waitForTracingChannelBinding(() => { + for (const channelName of awsSendChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + getSpan, + opts, + ); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven aws-sdk (v3) integration. + * + * Subscribes to the `orchestrion:@smithy/smithy-client:send` (and equivalent) diagnostics_channel + * the orchestrion code transform injects into the AWS SDK's smithy `Client.prototype.send`, emitting + * spans identical to the OTel `@opentelemetry/instrumentation-aws-sdk` integration (with a distinct + * `auto.aws.orchestrion.aws-sdk` origin). Requires the orchestrion runtime hook or bundler plugin — + * wire it up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const awsChannelIntegration = defineIntegration(_awsChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts new file mode 100644 index 000000000000..fb040764d194 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts @@ -0,0 +1,83 @@ +import type { Span } from '@sentry/core'; +import { + debug, + dynamicSamplingContextToSentryBaggageHeader, + getDynamicSamplingContextFromSpan, + spanToTraceHeader, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import type { SNS, SQS } from '../aws-sdk.types'; + +// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html +export const MAX_MESSAGE_ATTRIBUTES = 10; + +// Sentry trace-propagation headers written into / read from AWS message attributes. +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const BAGGAGE_HEADER = 'baggage'; +const PROPAGATION_FIELDS = [SENTRY_TRACE_HEADER, BAGGAGE_HEADER]; + +export interface AwsSdkContextObject { + [key: string]: { + StringValue?: string; + Value?: string; + }; +} + +/** Build the `sentry-trace`/`baggage` header pair carrying the span's trace context. */ +export function getPropagationHeaders(span: Span): Record { + const headers: Record = { + [SENTRY_TRACE_HEADER]: spanToTraceHeader(span), + }; + const baggage = dynamicSamplingContextToSentryBaggageHeader(getDynamicSamplingContextFromSpan(span)); + if (baggage) { + headers[BAGGAGE_HEADER] = baggage; + } + return headers; +} + +/** + * Inject the span's trace-propagation headers into an SQS/SNS message-attribute map, so the consumer + * can continue the trace. Respects the SQS 10-attribute quota. Mirrors the OTel integration's + * `injectPropagationContext`, but writes Sentry's `sentry-trace`/`baggage` instead of W3C headers. + */ +export function injectPropagationContext( + attributesMap: SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap | undefined, + span: Span, +): SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap { + const attributes = attributesMap ?? {}; + const headers = getPropagationHeaders(span); + const headerKeys = Object.keys(headers); + + if (Object.keys(attributes).length + headerKeys.length <= MAX_MESSAGE_ATTRIBUTES) { + for (const key of headerKeys) { + (attributes as AwsSdkContextObject)[key] = { DataType: 'String', StringValue: headers[key] } as any; + } + } else { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on SQS/SNS message due to maximum amount of MessageAttributes', + ); + } + return attributes; +} + +/** Read the propagation headers back off a received SQS message, if present. */ +export function extractPropagationHeaders( + message: SQS.Message, +): { sentryTrace?: string; baggage?: string } | undefined { + const carrier = (message.MessageAttributes ?? {}) as AwsSdkContextObject; + const sentryTrace = carrier[SENTRY_TRACE_HEADER]?.StringValue ?? carrier[SENTRY_TRACE_HEADER]?.Value; + if (!sentryTrace) { + return undefined; + } + return { + sentryTrace, + baggage: carrier[BAGGAGE_HEADER]?.StringValue ?? carrier[BAGGAGE_HEADER]?.Value, + }; +} + +export function addPropagationFieldsToAttributeNames(messageAttributeNames: string[] = []): string[] { + return messageAttributeNames.length + ? Array.from(new Set([...messageAttributeNames, ...PROPAGATION_FIELDS])) + : [...PROPAGATION_FIELDS]; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts new file mode 100644 index 000000000000..1ca4577fd1fb --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts @@ -0,0 +1,16 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; + +export type { RequestMetadata }; + +export interface ServiceExtension { + // called before the request is sent, and before the span is started + requestPreSpanHook: (request: NormalizedRequest) => RequestMetadata; + + // called before the request is sent, and after the span is started. `span` is the started span, + // used to derive trace-propagation headers injected into outgoing messages. + requestPostSpanHook?: (request: NormalizedRequest, span: Span) => void; + + // called after the response is received. If a value is returned, it replaces the response output. + responseHook?: (response: NormalizedResponse, span: Span) => any | undefined; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts new file mode 100644 index 000000000000..d84d7cb32a51 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -0,0 +1,47 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import { BedrockRuntimeServiceExtension } from './bedrock-runtime'; +import { DynamodbServiceExtension } from './dynamodb'; +import { KinesisServiceExtension } from './kinesis'; +import { LambdaServiceExtension } from './lambda'; +import { S3ServiceExtension } from './s3'; +import { SecretsManagerServiceExtension } from './secretsmanager'; +import type { ServiceExtension } from './ServiceExtension'; +import { SnsServiceExtension } from './sns'; +import { SqsServiceExtension } from './sqs'; +import { StepFunctionsServiceExtension } from './stepfunctions'; + +export class ServicesExtensions implements ServiceExtension { + private _services: Map; + + public constructor() { + this._services = new Map(); + this._services.set('SecretsManager', new SecretsManagerServiceExtension()); + this._services.set('SFN', new StepFunctionsServiceExtension()); + this._services.set('SQS', new SqsServiceExtension()); + this._services.set('SNS', new SnsServiceExtension()); + this._services.set('DynamoDB', new DynamodbServiceExtension()); + this._services.set('Lambda', new LambdaServiceExtension()); + this._services.set('S3', new S3ServiceExtension()); + this._services.set('Kinesis', new KinesisServiceExtension()); + this._services.set('BedrockRuntime', new BedrockRuntimeServiceExtension()); + } + + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const serviceExtension = this._services.get(request.serviceName); + if (!serviceExtension) { + return {}; + } + return serviceExtension.requestPreSpanHook(request); + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + const serviceExtension = this._services.get(request.serviceName); + serviceExtension?.requestPostSpanHook?.(request, span); + } + + public responseHook(response: NormalizedResponse, span: Span): any | undefined { + const serviceExtension = this._services.get(response.request.serviceName); + return serviceExtension?.responseHook?.(response, span); + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts new file mode 100644 index 000000000000..60de948a0fbc --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts @@ -0,0 +1,480 @@ +import type { Span } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import { + ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, + GEN_AI_OPERATION_NAME_VALUE_CHAT, + GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, +} from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +// Simplified types inlined from @aws-sdk/client-bedrock-runtime +// Only the fields accessed by this instrumentation are included +interface TokenUsage { + inputTokens: number | undefined; + outputTokens: number | undefined; + totalTokens: number | undefined; +} + +interface ConverseStreamOutput { + messageStop?: { stopReason?: string }; + metadata?: { usage?: TokenUsage }; + [key: string]: any; +} + +export class BedrockRuntimeServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + switch (request.commandName) { + case 'Converse': + return this._requestPreSpanHookConverse(request, false); + case 'ConverseStream': + return this._requestPreSpanHookConverse(request, true); + case 'InvokeModel': + return this._requestPreSpanHookInvokeModel(request, false); + case 'InvokeModelWithResponseStream': + return this._requestPreSpanHookInvokeModel(request, true); + } + + return {}; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (!span.isRecording()) { + return; + } + + switch (response.request.commandName) { + case 'Converse': + return this._responseHookConverse(response, span); + case 'ConverseStream': + return this._responseHookConverseStream(response, span); + case 'InvokeModel': + return this._responseHookInvokeModel(response, span); + case 'InvokeModelWithResponseStream': + return this._responseHookInvokeModelWithResponseStream(response, span); + } + } + + private _requestPreSpanHookConverse(request: NormalizedRequest, isStream: boolean): RequestMetadata { + let spanName = GEN_AI_OPERATION_NAME_VALUE_CHAT; + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + [GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT, + }; + + const modelId = request.commandInput.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + if (spanName) { + spanName += ` ${modelId}`; + } + } + + const inferenceConfig = request.commandInput.inferenceConfig; + if (inferenceConfig) { + const { maxTokens, temperature, topP, stopSequences } = inferenceConfig; + if (maxTokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = maxTokens; + } + if (temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = temperature; + } + if (topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = topP; + } + if (stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = stopSequences; + } + } + + return { + spanName, + isStream, + spanAttributes, + }; + } + + private _requestPreSpanHookInvokeModel(request: NormalizedRequest, isStream: boolean): RequestMetadata { + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + }; + + const modelId = request.commandInput?.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + } + + if (request.commandInput?.body) { + const requestBody = JSON.parse(request.commandInput.body); + if (modelId.includes('amazon.titan')) { + if (requestBody.textGenerationConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.textGenerationConfig.temperature; + } + if (requestBody.textGenerationConfig?.topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.textGenerationConfig.topP; + } + if (requestBody.textGenerationConfig?.maxTokenCount !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.textGenerationConfig.maxTokenCount; + } + if (requestBody.textGenerationConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.textGenerationConfig.stopSequences; + } + } else if (modelId.includes('amazon.nova')) { + if (requestBody.inferenceConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.inferenceConfig.temperature; + } + if (requestBody.inferenceConfig?.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.inferenceConfig.top_p; + } + if (requestBody.inferenceConfig?.max_new_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.inferenceConfig.max_new_tokens; + } + if (requestBody.inferenceConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.inferenceConfig.stopSequences; + } + } else if (modelId.includes('anthropic.claude')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('meta.llama')) { + if (requestBody.max_gen_len !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_gen_len; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + // request for meta llama models does not contain stop_sequences field + } else if (modelId.includes('cohere.command-r')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.message !== undefined) { + // NOTE: We approximate the token count since this value is not directly available in the body. + // According to Bedrock docs they use (total_chars / 6) to approximate token count for pricing. + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.message.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('cohere.command')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('mistral')) { + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop; + } + } + } + + return { + isStream, + spanAttributes, + }; + } + + private _responseHookConverse(response: NormalizedResponse, span: Span): void { + const { stopReason, usage } = response.data; + + setStopReason(span, stopReason); + setUsage(span, usage); + } + + private _responseHookConverseStream(response: NormalizedResponse, span: Span): void { + // Wrap and replace the response stream in place to process events into telemetry + // before yielding to the user. + response.data.stream = wrapConverseStreamResponse(response.data.stream, span); + } + + private _responseHookInvokeModel(response: NormalizedResponse, span: Span): void { + const currentModelId = response.request.commandInput?.modelId; + if (response.data?.body) { + const decodedResponseBody = new TextDecoder().decode(response.data.body); + const responseBody = JSON.parse(decodedResponseBody); + if (currentModelId.includes('amazon.titan')) { + if (responseBody.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.inputTextTokenCount); + } + if (responseBody.results?.[0]?.tokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.results[0].tokenCount); + } + if (responseBody.results?.[0]?.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.results[0].completionReason]); + } + } else if (currentModelId.includes('amazon.nova')) { + if (responseBody.usage !== undefined) { + if (responseBody.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.inputTokens); + } + if (responseBody.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.outputTokens); + } + } + if (responseBody.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stopReason]); + } + } else if (currentModelId.includes('anthropic.claude')) { + if (responseBody.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.input_tokens); + } + if (responseBody.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.output_tokens); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('meta.llama')) { + if (responseBody.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.prompt_token_count); + } + if (responseBody.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.generation_token_count); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('cohere.command-r')) { + if (responseBody.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.text.length / 6)); + } + if (responseBody.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.finish_reason]); + } + } else if (currentModelId.includes('cohere.command')) { + if (responseBody.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.generations[0].text.length / 6)); + } + if (responseBody.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.generations[0].finish_reason]); + } + } else if (currentModelId.includes('mistral')) { + if (responseBody.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.outputs[0].text.length / 6)); + } + if (responseBody.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.outputs[0].stop_reason]); + } + } + } + } + + private _responseHookInvokeModelWithResponseStream(response: NormalizedResponse, span: Span): void { + const stream = response.data?.body; + const modelId = response.request.commandInput?.modelId; + if (!stream || !modelId) { + return; + } + + // Replace the original response body with our instrumented stream, deferring span.end() until the + // entire stream is consumed. Downstream consumers still receive the full stream. + response.data.body = (async function* () { + try { + for await (const chunk of stream) { + const parsedChunk = parseChunk(chunk?.chunk?.bytes); + + if (!parsedChunk) { + // pass through + } else if (modelId.includes('amazon.titan')) { + recordTitanAttributes(parsedChunk, span); + } else if (modelId.includes('anthropic.claude')) { + recordClaudeAttributes(parsedChunk, span); + } else if (modelId.includes('amazon.nova')) { + recordNovaAttributes(parsedChunk, span); + } else if (modelId.includes('meta.llama')) { + recordLlamaAttributes(parsedChunk, span); + } else if (modelId.includes('cohere.command-r')) { + recordCohereRAttributes(parsedChunk, span); + } else if (modelId.includes('cohere.command')) { + recordCohereAttributes(parsedChunk, span); + } else if (modelId.includes('mistral')) { + recordMistralAttributes(parsedChunk, span); + } + yield chunk; + } + } finally { + span.end(); + } + })(); + } +} + +async function* wrapConverseStreamResponse( + stream: AsyncIterable, + span: Span, +): AsyncGenerator { + try { + let usage: TokenUsage | undefined; + for await (const item of stream) { + setStopReason(span, item.messageStop?.stopReason); + usage = item.metadata?.usage; + yield item; + } + setUsage(span, usage); + } finally { + span.end(); + } +} + +function setStopReason(span: Span, stopReason: string | undefined): void { + if (stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [stopReason]); + } +} + +function setUsage(span: Span, usage: TokenUsage | undefined): void { + if (usage) { + const { inputTokens, outputTokens } = usage; + if (inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, inputTokens); + } + if (outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens); + } + } +} + +function parseChunk(bytes?: Uint8Array): any { + if (!bytes || !(bytes instanceof Uint8Array)) { + return null; + } + try { + const str = Buffer.from(bytes).toString('utf-8'); + return JSON.parse(str); + } catch (err) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] failed to parse streamed bedrock chunk', err); + return null; + } +} + +function recordNovaAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.metadata?.usage !== undefined) { + if (parsedChunk.metadata?.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.metadata.usage.inputTokens); + } + if (parsedChunk.metadata?.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.metadata.usage.outputTokens); + } + } + if (parsedChunk.messageStop?.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.messageStop.stopReason]); + } +} + +function recordClaudeAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.message?.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.message.usage.input_tokens); + } + if (parsedChunk.message?.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.message.usage.output_tokens); + } + if (parsedChunk.delta?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.delta.stop_reason]); + } +} + +function recordTitanAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.inputTextTokenCount); + } + if (parsedChunk.totalOutputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.totalOutputTextTokenCount); + } + if (parsedChunk.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.completionReason]); + } +} + +function recordLlamaAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.prompt_token_count); + } + if (parsedChunk.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.generation_token_count); + } + if (parsedChunk.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.stop_reason]); + } +} + +function recordMistralAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.outputs[0].text.length / 6)); + } + if (parsedChunk.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.outputs[0].stop_reason]); + } +} + +function recordCohereAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.generations[0].text.length / 6)); + } + if (parsedChunk.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.generations[0].finish_reason]); + } +} + +function recordCohereRAttributes(parsedChunk: any, span: Span): void { + if (parsedChunk.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.text.length / 6)); + } + if (parsedChunk.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.finish_reason]); + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts new file mode 100644 index 000000000000..c735309e505f --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts @@ -0,0 +1,184 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { DB_NAME, DB_OPERATION, DB_SYSTEM } from '@sentry/conventions/attributes'; +import { + ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS, + ATTR_AWS_DYNAMODB_CONSISTENT_READ, + ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + ATTR_AWS_DYNAMODB_COUNT, + ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE, + ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES, + ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES, + ATTR_AWS_DYNAMODB_INDEX_NAME, + ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + ATTR_AWS_DYNAMODB_LIMIT, + ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES, + ATTR_AWS_DYNAMODB_PROJECTION, + ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY, + ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY, + ATTR_AWS_DYNAMODB_SCAN_FORWARD, + ATTR_AWS_DYNAMODB_SCANNED_COUNT, + ATTR_AWS_DYNAMODB_SEGMENT, + ATTR_AWS_DYNAMODB_SELECT, + ATTR_AWS_DYNAMODB_TABLE_COUNT, + ATTR_AWS_DYNAMODB_TABLE_NAMES, + ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS, + DB_SYSTEM_VALUE_DYNAMODB, +} from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +function toArray(values: T | T[]): T[] { + return Array.isArray(values) ? values : [values]; +} + +export class DynamodbServiceExtension implements ServiceExtension { + public requestPreSpanHook(normalizedRequest: NormalizedRequest): RequestMetadata { + const operation = normalizedRequest.commandName; + const tableName = normalizedRequest.commandInput?.TableName; + + const spanAttributes: Record = {}; + + /* oxlint-disable typescript/no-deprecated -- old-semconv db.* attributes, matched to the OTel aws-sdk integration */ + spanAttributes[DB_SYSTEM] = DB_SYSTEM_VALUE_DYNAMODB; + spanAttributes[DB_NAME] = tableName; + spanAttributes[DB_OPERATION] = operation; + /* oxlint-enable typescript/no-deprecated */ + + // `RequestItems` is undefined when no table names are returned; its keys are the table names. + if (normalizedRequest.commandInput?.TableName) { + // Necessary for commands with only 1 table name (e.g. CreateTable). Attribute is `TableName`, not keys of `RequestItems`. + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = [normalizedRequest.commandInput.TableName]; + } else if (normalizedRequest.commandInput?.RequestItems) { + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = Object.keys(normalizedRequest.commandInput.RequestItems); + } + + if (operation === 'CreateTable' || operation === 'UpdateTable') { + // only check for ProvisionedThroughput since ReadCapacityUnits and WriteCapacityUnits are required attributes + if (normalizedRequest.commandInput?.ProvisionedThroughput) { + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.ReadCapacityUnits; + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.WriteCapacityUnits; + } + } + + if (operation === 'GetItem' || operation === 'Scan' || operation === 'Query') { + if (normalizedRequest.commandInput?.ConsistentRead) { + spanAttributes[ATTR_AWS_DYNAMODB_CONSISTENT_READ] = normalizedRequest.commandInput.ConsistentRead; + } + } + + if (operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.ProjectionExpression) { + spanAttributes[ATTR_AWS_DYNAMODB_PROJECTION] = normalizedRequest.commandInput.ProjectionExpression; + } + } + + if (operation === 'CreateTable') { + if (normalizedRequest.commandInput?.GlobalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexes, + ).map((x: Record) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.LocalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.LocalSecondaryIndexes, + ).map((x: Record) => JSON.stringify(x)); + } + } + + if (operation === 'ListTables' || operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.Limit) { + spanAttributes[ATTR_AWS_DYNAMODB_LIMIT] = normalizedRequest.commandInput.Limit; + } + } + + if (operation === 'ListTables') { + if (normalizedRequest.commandInput?.ExclusiveStartTableName) { + spanAttributes[ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE] = + normalizedRequest.commandInput.ExclusiveStartTableName; + } + } + + if (operation === 'Query') { + if (normalizedRequest.commandInput?.ScanIndexForward) { + spanAttributes[ATTR_AWS_DYNAMODB_SCAN_FORWARD] = normalizedRequest.commandInput.ScanIndexForward; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'Scan') { + if (normalizedRequest.commandInput?.Segment) { + spanAttributes[ATTR_AWS_DYNAMODB_SEGMENT] = normalizedRequest.commandInput?.Segment; + } + + if (normalizedRequest.commandInput?.TotalSegments) { + spanAttributes[ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS] = normalizedRequest.commandInput?.TotalSegments; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'UpdateTable') { + if (normalizedRequest.commandInput?.AttributeDefinitions) { + spanAttributes[ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS] = toArray( + normalizedRequest.commandInput.AttributeDefinitions, + ).map((x: Record) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.GlobalSecondaryIndexUpdates) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexUpdates, + ).map((x: Record) => JSON.stringify(x)); + } + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.data?.ConsumedCapacity) { + span.setAttribute( + ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + toArray(response.data.ConsumedCapacity).map((x: Record) => JSON.stringify(x)), + ); + } + + if (response.data?.ItemCollectionMetrics) { + span.setAttribute( + ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + toArray(response.data.ItemCollectionMetrics).map((x: Record) => JSON.stringify(x)), + ); + } + + if (response.data?.TableNames) { + span.setAttribute(ATTR_AWS_DYNAMODB_TABLE_COUNT, response.data?.TableNames.length); + } + + if (response.data?.Count) { + span.setAttribute(ATTR_AWS_DYNAMODB_COUNT, response.data?.Count); + } + + if (response.data?.ScannedCount) { + span.setAttribute(ATTR_AWS_DYNAMODB_SCANNED_COUNT, response.data?.ScannedCount); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts new file mode 100644 index 000000000000..ef438e065534 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts @@ -0,0 +1 @@ +export { ServicesExtensions } from './ServicesExtensions'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts new file mode 100644 index 000000000000..aee76d924bdb --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts @@ -0,0 +1,20 @@ +import { SPAN_KIND } from '@sentry/core'; +import { AWS_KINESIS_STREAM_NAME } from '../constants'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class KinesisServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const streamName = request.commandInput?.StreamName; + const spanAttributes: Record = {}; + + if (streamName) { + spanAttributes[AWS_KINESIS_STREAM_NAME] = streamName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts new file mode 100644 index 000000000000..133c87bdf725 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts @@ -0,0 +1,86 @@ +import type { Span } from '@sentry/core'; +import { debug, SPAN_KIND } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import { + ATTR_FAAS_EXECUTION, + ATTR_FAAS_INVOKED_NAME, + ATTR_FAAS_INVOKED_PROVIDER, + ATTR_FAAS_INVOKED_REGION, +} from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { getPropagationHeaders } from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +const INVOKE_COMMAND = 'Invoke'; + +export class LambdaServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const functionName = request.commandInput?.FunctionName; + + let spanAttributes: Record = {}; + let spanName: string | undefined; + + switch (request.commandName) { + case INVOKE_COMMAND: + spanAttributes = { + [ATTR_FAAS_INVOKED_NAME]: functionName, + [ATTR_FAAS_INVOKED_PROVIDER]: 'aws', + }; + if (request.region) { + spanAttributes[ATTR_FAAS_INVOKED_REGION] = request.region; + } + spanName = `${functionName} ${INVOKE_COMMAND}`; + break; + } + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === INVOKE_COMMAND && request.commandInput) { + request.commandInput.ClientContext = injectLambdaPropagationContext(request.commandInput.ClientContext, span); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.request.commandName === INVOKE_COMMAND) { + span.setAttribute(ATTR_FAAS_EXECUTION, response.requestId); + } + } +} + +function injectLambdaPropagationContext(clientContext: string | undefined, span: Span): string | undefined { + try { + const propagatedContext = getPropagationHeaders(span); + + const parsedClientContext = clientContext ? JSON.parse(Buffer.from(clientContext, 'base64').toString('utf8')) : {}; + + const updatedClientContext = { + ...parsedClientContext, + custom: { + ...parsedClientContext.custom, + ...propagatedContext, + }, + }; + + const encodedClientContext = Buffer.from(JSON.stringify(updatedClientContext)).toString('base64'); + + // The length of client context is capped at 3583 bytes of base64 encoded data + // (https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_RequestSyntax) + if (encodedClientContext.length > 3583) { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on lambda invoke parameters due to ClientContext length limitations.', + ); + return clientContext; + } + + return encodedClientContext; + } catch (e) { + DEBUG_BUILD && debug.log('[orchestrion:aws-sdk] failed to set trace propagation on lambda ClientContext', e); + return clientContext; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts new file mode 100644 index 000000000000..1719c8e77d62 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts @@ -0,0 +1,20 @@ +import { SPAN_KIND } from '@sentry/core'; +import { AWS_S3_BUCKET } from '../constants'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class S3ServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const bucketName = request.commandInput?.Bucket; + const spanAttributes: Record = {}; + + if (bucketName) { + spanAttributes[AWS_S3_BUCKET] = bucketName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts new file mode 100644 index 000000000000..e10378815ffe --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts @@ -0,0 +1,27 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { ATTR_AWS_SECRETSMANAGER_SECRET_ARN } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SecretsManagerServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const secretId = request.commandInput?.SecretId; + const spanAttributes: Record = {}; + if (typeof secretId === 'string' && secretId.startsWith('arn:aws:secretsmanager:')) { + spanAttributes[ATTR_AWS_SECRETSMANAGER_SECRET_ARN] = secretId; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const secretArn = response.data?.ARN; + if (secretArn) { + span.setAttribute(ATTR_AWS_SECRETSMANAGER_SECRET_ARN, secretArn); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts new file mode 100644 index 000000000000..4f50021cd1c2 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts @@ -0,0 +1,74 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { MESSAGING_DESTINATION_NAME, MESSAGING_SYSTEM } from '@sentry/conventions/attributes'; +import { + ATTR_AWS_SNS_TOPIC_ARN, + ATTR_MESSAGING_DESTINATION, + ATTR_MESSAGING_DESTINATION_KIND, + MESSAGING_DESTINATION_KIND_VALUE_TOPIC, +} from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { injectPropagationContext } from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SnsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName = `SNS ${request.commandName}`; + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws.sns', + }; + + if (request.commandName === 'Publish') { + spanKind = SPAN_KIND.PRODUCER; + + spanAttributes[ATTR_MESSAGING_DESTINATION_KIND] = MESSAGING_DESTINATION_KIND_VALUE_TOPIC; + const { TopicArn, TargetArn, PhoneNumber } = request.commandInput; + const destinationName = extractDestinationName(TopicArn, TargetArn, PhoneNumber); + spanAttributes[ATTR_MESSAGING_DESTINATION] = destinationName; + spanAttributes[MESSAGING_DESTINATION_NAME] = TopicArn || TargetArn || PhoneNumber || 'unknown'; + + spanName = `${PhoneNumber ? 'phone_number' : destinationName} send`; + } + + const topicArn = request.commandInput?.TopicArn; + if (topicArn) { + spanAttributes[ATTR_AWS_SNS_TOPIC_ARN] = topicArn; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === 'Publish') { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext(origMessageAttributes, span); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const topicArn = response.data?.TopicArn; + if (topicArn) { + span.setAttribute(ATTR_AWS_SNS_TOPIC_ARN, topicArn); + } + } +} + +function extractDestinationName(topicArn: string, targetArn: string, phoneNumber: string): string { + if (topicArn || targetArn) { + const arn = topicArn ?? targetArn; + try { + return arn.substring(arn.lastIndexOf(':') + 1); + } catch { + return arn; + } + } else if (phoneNumber) { + return phoneNumber; + } else { + return 'unknown'; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts new file mode 100644 index 000000000000..27fb71dcb490 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts @@ -0,0 +1,136 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { propagationContextFromHeaders, SPAN_KIND } from '@sentry/core'; +import { + MESSAGING_BATCH_MESSAGE_COUNT, + MESSAGING_DESTINATION_NAME, + MESSAGING_MESSAGE_ID, + MESSAGING_OPERATION_TYPE, + MESSAGING_SYSTEM, + URL_FULL, +} from '@sentry/conventions/attributes'; +import type { SQS } from '../aws-sdk.types'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { + addPropagationFieldsToAttributeNames, + extractPropagationHeaders, + injectPropagationContext, +} from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SqsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const queueUrl = extractQueueUrl(request.commandInput); + const queueName = extractQueueNameFromUrl(queueUrl); + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName: string | undefined; + + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws_sqs', + [MESSAGING_DESTINATION_NAME]: queueName, + [URL_FULL]: queueUrl, + }; + + switch (request.commandName) { + case 'ReceiveMessage': + { + spanKind = SPAN_KIND.CONSUMER; + spanName = `${queueName} receive`; + spanAttributes[MESSAGING_OPERATION_TYPE] = 'receive'; + + request.commandInput.MessageAttributeNames = addPropagationFieldsToAttributeNames( + request.commandInput.MessageAttributeNames, + ); + } + break; + + case 'SendMessage': + case 'SendMessageBatch': + spanKind = SPAN_KIND.PRODUCER; + spanName = `${queueName} send`; + break; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + switch (request.commandName) { + case 'SendMessage': + { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext(origMessageAttributes, span); + } + break; + + case 'SendMessageBatch': + { + const entries = request.commandInput?.Entries; + if (Array.isArray(entries)) { + entries.forEach((messageParams: { MessageAttributes: SQS.MessageBodyAttributeMap }) => { + messageParams.MessageAttributes = injectPropagationContext(messageParams.MessageAttributes ?? {}, span); + }); + } + } + break; + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + switch (response.request.commandName) { + case 'SendMessage': + span.setAttribute(MESSAGING_MESSAGE_ID, response?.data?.MessageId); + break; + + case 'SendMessageBatch': + break; + + case 'ReceiveMessage': { + const messages: SQS.Message[] = response?.data?.Messages || []; + + span.setAttribute(MESSAGING_BATCH_MESSAGE_COUNT, messages.length); + + for (const message of messages) { + const headers = extractPropagationHeaders(message); + if (!headers) { + continue; + } + + const { parentSpanId, traceId, sampled } = propagationContextFromHeaders( + headers.sentryTrace, + headers.baggage, + ); + if (traceId && parentSpanId) { + span.addLink({ + context: { + traceId, + spanId: parentSpanId, + traceFlags: sampled ? 1 : 0, + }, + attributes: { + [MESSAGING_MESSAGE_ID]: message.MessageId, + }, + }); + } + } + break; + } + } + } +} + +function extractQueueUrl(commandInput: Record): string { + return commandInput?.QueueUrl; +} + +function extractQueueNameFromUrl(queueUrl: string): string | undefined { + if (!queueUrl) return undefined; + + const segments = queueUrl.split('/'); + if (segments.length === 0) return undefined; + + return segments[segments.length - 1]; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts new file mode 100644 index 000000000000..b08f452288b7 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts @@ -0,0 +1,25 @@ +import { SPAN_KIND } from '@sentry/core'; +import { ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN, ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN } from '../constants'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class StepFunctionsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const stateMachineArn = request.commandInput?.stateMachineArn; + const activityArn = request.commandInput?.activityArn; + const spanAttributes: Record = {}; + + if (stateMachineArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN] = stateMachineArn; + } + + if (activityArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN] = activityArn; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts new file mode 100644 index 000000000000..d46e813eacce --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -0,0 +1,31 @@ +import type { SpanKindValue } from '@sentry/core'; + +export type CommandInput = Record; + +/** + * These are normalized request and response. They organize the relevant data in one interface which + * can be processed in a uniform manner in the per-service hooks. + */ +export interface NormalizedRequest { + serviceName: string; + commandName: string; + commandInput: CommandInput; + region?: string; +} + +export interface NormalizedResponse { + data: any; + request: NormalizedRequest; + requestId?: string; +} + +/** Span metadata a per-service extension returns for the subscriber to build the span from. */ +export interface RequestMetadata { + // If true, then the response is a stream so the subscriber must not end the span when `send` settles. + // The service extension ends the span itself, generally by wrapping the stream and ending after it is + // consumed. + isStream?: boolean; + spanAttributes?: Record; + spanKind?: SpanKindValue; + spanName?: string; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts new file mode 100644 index 000000000000..4af89127f6f4 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -0,0 +1,31 @@ +import { RPC_METHOD, RPC_SERVICE } from '@sentry/conventions/attributes'; +import { ATTR_RPC_SYSTEM, CLOUD_REGION } from './constants'; +import type { NormalizedRequest } from './types'; + +export function removeSuffixFromStringIfExists(str: string, suffixToRemove: string): string { + const suffixLength = suffixToRemove.length; + return str?.slice(-suffixLength) === suffixToRemove ? str.slice(0, str.length - suffixLength) : str; +} + +export function normalizeV3Request( + serviceName: string, + commandNameWithSuffix: string, + commandInput: Record, + region: string | undefined, +): NormalizedRequest { + return { + serviceName: serviceName?.replace(/\s+/g, ''), + commandName: removeSuffixFromStringIfExists(commandNameWithSuffix, 'Command'), + commandInput, + region, + }; +} + +export function extractAttributesFromNormalizedRequest(normalizedRequest: NormalizedRequest): Record { + return { + [ATTR_RPC_SYSTEM]: 'aws-api', + [RPC_METHOD]: normalizedRequest.commandName, + [RPC_SERVICE]: normalizedRequest.serviceName, + [CLOUD_REGION]: normalizedRequest.region, + }; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index a714fcbce94b..e14dc38cccfe 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -11,6 +11,7 @@ import { hapiChannels } from './config/hapi'; import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; import { graphqlChannels } from './config/graphql'; +import { awsSdkChannels } from './config/aws-sdk'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -39,6 +40,7 @@ export const CHANNELS = { ...redisChannels, ...expressChannels, ...graphqlChannels, + ...awsSdkChannels, } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts new file mode 100644 index 000000000000..6b84ef73368f --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -0,0 +1,34 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +// The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which +// package hosts that `Client` class changed across versions, so we target all of them; only the one +// the app's client actually extends is ever invoked. +// +// - `@smithy/core` >= 3.24.0: the `Client` class moved into the `client` submodule bundle. +// - `@smithy/smithy-client`: the `Client` class for aws-sdk v3.363.0+ (pre-`@smithy/core` stack). +// - `@aws-sdk/smithy-client`: the `Client` class for older aws-sdk v3 releases. +// +// `send` is `async send(command, options)` (returns a promise), so `kind: 'Async'`. +export const awsSdkConfig = [ + { + channelName: 'send', + module: { name: '@smithy/core', versionRange: '>=3.24.0', filePath: 'dist-cjs/submodules/client/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@smithy/smithy-client', versionRange: '>=1.0.3', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@aws-sdk/smithy-client', versionRange: '^3.1.0', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const awsSdkChannels = { + AWS_SMITHY_CORE_SEND: 'orchestrion:@smithy/core:send', + AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', + AWS_SDK_SMITHY_CLIENT_SEND: 'orchestrion:@aws-sdk/smithy-client:send', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index bf8fb24b2f21..71ba194e5e60 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -12,6 +12,7 @@ import { hapiConfig } from './hapi'; import { redisConfig } from './redis'; import { expressConfig } from './express'; import { graphqlConfig } from './graphql'; +import { awsSdkConfig } from './aws-sdk'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -27,6 +28,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...redisConfig, ...expressConfig, ...graphqlConfig, + ...awsSdkConfig, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 09e5ce88f295..ce3fd5f7c890 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,4 +1,5 @@ import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; +import { awsChannelIntegration } from '../integrations/tracing-channel/aws-sdk'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; import { graphqlChannelIntegration, @@ -17,6 +18,7 @@ import { expressChannelIntegration } from '../integrations/tracing-channel/expre export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; export { anthropicChannelIntegration, + awsChannelIntegration, googleGenAIChannelIntegration, graphqlChannelIntegration, hapiChannelIntegration, @@ -63,4 +65,5 @@ export const channelIntegrations = { hapiIntegration: hapiChannelIntegration, expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, + awsIntegration: awsChannelIntegration, } as const; From 46c35c3fdf5ab1b184b84b14e289e70f1a8224a2 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 14:55:01 +0200 Subject: [PATCH 2/2] Add Bedrock integration tests (Converse and InvokeModel) --- .../aws-serverless/bedrock/instrument.mjs | 9 ++ .../aws-serverless/bedrock/scenario.mjs | 84 +++++++++++++++++++ .../suites/aws-serverless/bedrock/test.ts | 75 +++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs new file mode 100644 index 000000000000..fe1c5c47983f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/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/bedrock/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs new file mode 100644 index 000000000000..c1705ff0d403 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { BedrockRuntimeClient, ConverseCommand, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +// Force the HTTP/1 handler so `nock` can intercept the request 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' }; +const host = `https://bedrock-runtime.${region}.amazonaws.com`; + +async function converse() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/converse$/) + .reply( + 200, + JSON.stringify({ + output: { message: { role: 'assistant', content: [{ text: 'Hello from Bedrock' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new ConverseCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + inferenceConfig: { maxTokens: 100, temperature: 0.5, topP: 0.9 }, + }), + ); +} + +async function invokeModel() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/invoke$/) + .reply( + 200, + JSON.stringify({ + content: [{ type: 'text', text: 'Hello from Bedrock' }], + stop_reason: 'end_turn', + usage: { input_tokens: 15, output_tokens: 9 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new InvokeModelCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + contentType: 'application/json', + body: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 100, + temperature: 0.5, + top_p: 0.9, + messages: [{ role: 'user', content: 'Hello' }], + }), + }), + ); +} + +async function run() { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await converse(); + await invokeModel(); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts new file mode 100644 index 000000000000..e8e6e9c337e5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -0,0 +1,75 @@ +import type { TransactionEvent } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same gen_ai spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws-sdk' : 'auto.otel.aws'; + +const MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; + +function assertBedrockSpans(transaction: TransactionEvent): void { + const spans = transaction.spans ?? []; + + expect(transaction.transaction).toBe('Test Transaction'); + + // Converse (non-streaming) + expect(spans, 'expected a Bedrock Converse span').toContainEqual( + expect.objectContaining({ + description: `chat ${MODEL_ID}`, + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); + + // InvokeModel (non-streaming, anthropic.claude request/response body) + expect(spans, 'expected a Bedrock InvokeModel span').toContainEqual( + expect.objectContaining({ + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 9, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); +} + +describe('awsIntegration - Bedrock', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ transaction: assertBedrockSpans }).start().completed(); + }); + }, + { additionalDependencies: { '@aws-sdk/client-bedrock-runtime': '^3.1046.0' } }, + ); +});