From 58336ba8530b9f207a250a32454807c75fe227a4 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:15:39 +0200 Subject: [PATCH 01/13] feat(server-utils): Add orchestrion aws-sdk channel integration core Adds the core of an OTel-free, orchestrion diagnostics-channel implementation of the aws-sdk (v3) instrumentation to `@sentry/server-utils`. The integration subscribes to the `orchestrion::send` channels the transform injects into the smithy `Client.prototype.send` (`@smithy/core`, `@smithy/smithy-client`, `@aws-sdk/smithy-client`) and emits a client rpc span per command (`rpc.system`/`rpc.method`/`rpc.service`, `cloud.region`, request id metadata), with a distinct `auto.aws.orchestrion.aws-sdk` origin. The per-service extension registry (span names, messaging/db/gen_ai attributes, trace propagation) starts empty; the services are ported one group at a time in follow-up PRs, mirroring the OTel integration's ServiceExtension contract. Registered in `channelIntegrations`, so it is reachable via `@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`. The `@sentry/aws-serverless` swap follows at the top of the stack. Part of #20946 Co-Authored-By: Claude Fable 5 --- .oxlintrc.base.json | 11 + .../tracing-channel/aws-sdk/constants.ts | 17 ++ .../tracing-channel/aws-sdk/index.ts | 205 ++++++++++++++++++ .../aws-sdk/services/ServiceExtension.ts | 16 ++ .../aws-sdk/services/ServicesExtensions.ts | 31 +++ .../tracing-channel/aws-sdk/services/index.ts | 1 + .../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 + 12 files changed, 384 insertions(+) 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/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/index.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/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..27d886365ab5 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -0,0 +1,17 @@ +/** + * 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'; 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/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..8c921c318e6f --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -0,0 +1,31 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import type { ServiceExtension } from './ServiceExtension'; + +export class ServicesExtensions implements ServiceExtension { + private _services: Map; + + public constructor() { + // Per-service extensions, keyed by the client's `serviceId` (e.g. `'S3'`). Services without a + // registered extension still get the base rpc span from the subscriber. + this._services = new Map(); + } + + 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/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/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 26f10d2fd2c1..4c39e485a2f1 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,5 +1,6 @@ import { amqplibChannels } from './config/amqplib'; import { anthropicAiChannels } from './config/anthropic-ai'; +import { awsSdkChannels } from './config/aws-sdk'; import { dataloaderChannels } from './config/dataloader'; import { expressChannels } from './config/express'; import { firebaseChannels } from './config/firebase'; @@ -46,6 +47,7 @@ import { vercelAiChannels } from './config/vercel-ai'; export const CHANNELS = { ...amqplibChannels, ...anthropicAiChannels, + ...awsSdkChannels, ...dataloaderChannels, ...expressChannels, ...firebaseChannels, 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 457684548b0d..9de43d49ed4d 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -2,6 +2,7 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { uniq } from '@sentry/core'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; +import { awsSdkConfig } from './aws-sdk'; import { dataloaderConfig } from './dataloader'; import { expressConfig } from './express'; import { firebaseConfig } from './firebase'; @@ -34,6 +35,7 @@ import { vercelAiConfig } from './vercel-ai'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...amqplibConfig, ...anthropicAiConfig, + ...awsSdkConfig, ...dataloaderConfig, ...expressConfig, ...firebaseConfig, diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 1933978787fc..f38962a1abdb 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,5 +1,6 @@ import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib'; 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, @@ -20,6 +21,7 @@ export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; export { amqplibChannelIntegration, anthropicChannelIntegration, + awsChannelIntegration, googleGenAIChannelIntegration, graphqlChannelIntegration, hapiChannelIntegration, @@ -69,4 +71,5 @@ export const channelIntegrations = { expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, + awsIntegration: awsChannelIntegration, } as const; From d95589d2ef5677dd04a42df3a504c2c9b0d6a313 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:32:35 +0200 Subject: [PATCH 02/13] Set sentry.op explicitly on aws-sdk channel spans Peer channel integrations (mysql, ioredis, anthropic) set the op at span start. Default to rpc, matching what the exporter infers from rpc.service, and let service extensions override it via RequestMetadata.spanOp where inference yields a different op. --- .../src/integrations/tracing-channel/aws-sdk/index.ts | 3 +++ .../src/integrations/tracing-channel/aws-sdk/types.ts | 2 ++ 2 files changed, 5 insertions(+) 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 index bc862a96bc29..3e07a5d5deeb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -103,6 +103,9 @@ const _awsChannelIntegration = (() => { const span = startInactiveSpan({ name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, kind: requestMetadata.spanKind ?? SPAN_KIND.CLIENT, + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; + // service extensions override it where inference yields a different op (DynamoDB: `db`). + op: requestMetadata.spanOp ?? 'rpc', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, ...extractAttributesFromNormalizedRequest(normalizedRequest), 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 index d46e813eacce..493e9dfa15c8 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -28,4 +28,6 @@ export interface RequestMetadata { spanAttributes?: Record; spanKind?: SpanKindValue; spanName?: string; + // Overrides the default `rpc` span op (e.g. `db` for DynamoDB). + spanOp?: string; } From 5c7ee84dd733056ade1347dc040d04c0523699d8 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:42:22 +0200 Subject: [PATCH 03/13] Make responseHook contract explicit about in-place response mutation The return-value override from the vendored OTel path cannot work through a tracing channel: subscribers cannot replace the value the caller's promise resolves with, so the subscriber rightly ignores any return value. Type responseHook as void and document that response changes (e.g. wrapping a stream) must mutate response.data in place, which is what the extensions do. --- .../tracing-channel/aws-sdk/services/ServiceExtension.ts | 7 +++++-- .../tracing-channel/aws-sdk/services/ServicesExtensions.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index 1ca4577fd1fb..5920f89f3346 100644 --- 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 @@ -11,6 +11,9 @@ export interface ServiceExtension { // 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; + // Called after the response is received. Unlike the OTel middleware patch, a tracing-channel + // subscriber cannot replace the value the caller's promise resolves with (`data.result` is not + // writable through the channel), so extensions that need to alter the response, e.g. to wrap a + // stream, must mutate `response.data` in place. + responseHook?: (response: NormalizedResponse, span: Span) => void; } 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 index 8c921c318e6f..2cc308150307 100644 --- 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 @@ -24,8 +24,8 @@ export class ServicesExtensions implements ServiceExtension { serviceExtension?.requestPostSpanHook?.(request, span); } - public responseHook(response: NormalizedResponse, span: Span): any | undefined { + public responseHook(response: NormalizedResponse, span: Span): void { const serviceExtension = this._services.get(response.request.serviceName); - return serviceExtension?.responseHook?.(response, span); + serviceExtension?.responseHook?.(response, span); } } From ed4b98c5d4799ea2147aa6bb403099f925efa4c0 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:48:45 +0200 Subject: [PATCH 04/13] Document why in-place response mutation is safe through the channel --- .../tracing-channel/aws-sdk/services/ServiceExtension.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 5920f89f3346..071f9005b8f0 100644 --- 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 @@ -12,8 +12,11 @@ export interface ServiceExtension { requestPostSpanHook?: (request: NormalizedRequest, span: Span) => void; // Called after the response is received. Unlike the OTel middleware patch, a tracing-channel - // subscriber cannot replace the value the caller's promise resolves with (`data.result` is not - // writable through the channel), so extensions that need to alter the response, e.g. to wrap a - // stream, must mutate `response.data` in place. + // subscriber cannot replace the value the caller's promise resolves with: the injected settle + // handler returns the captured result, not `ctx.result`. It does however publish `asyncEnd` + // synchronously BEFORE the caller's continuations run, and `response.data` is the same object the + // caller receives, so extensions that need to alter the response (e.g. wrap a stream) must mutate + // `response.data` in place; the mutation is guaranteed to be visible to the caller. Same idiom as + // the vercel-ai subscribers' `result.stream` tap. responseHook?: (response: NormalizedResponse, span: Span) => void; } From 7ef8b21aacdf5eefecca8c00bc752dc250ee680a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 00:07:57 +0200 Subject: [PATCH 05/13] Hold span end for pending region resolution The region provider resolves asynchronously while send proceeds; when send settled first (e.g. an early failure) the fire-and-forget backfill hit an already-ended span and cloud.region was lost. deferSpanEnd now keeps the span open until the region promise settles. In the common path the SDK awaits the region internally before sending, so the promise is long settled and nothing changes. --- .../tracing-channel/aws-sdk/index.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) 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 index 3e07a5d5deeb..e6527b278eb9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -31,6 +31,7 @@ interface AwsSendChannelContext { error?: unknown; _sentryNormalizedRequest?: NormalizedRequest; _sentryRequestMetadata?: RequestMetadata; + _sentryRegion?: { settled: boolean; promise: Promise }; } interface AwsClientConfig { @@ -116,9 +117,12 @@ const _awsChannelIntegration = (() => { 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?.()) + // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay + // the traced call the way the OTel middleware does). Backfill it onto the span and the + // normalized request once available; `deferSpanEnd` holds the span open until this settles + // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). + const regionHolder = { settled: false, promise: Promise.resolve() }; + regionHolder.promise = Promise.resolve(clientConfig?.region?.()) .then(region => { if (region) { normalizedRequest.region = region; @@ -127,7 +131,11 @@ const _awsChannelIntegration = (() => { }) .catch(() => { // Nothing to do; continue without a region. + }) + .finally(() => { + regionHolder.settled = true; }); + data._sentryRegion = regionHolder; // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before // `send` proceeds, so the mutated `commandInput` is used to build the request. @@ -137,7 +145,7 @@ const _awsChannelIntegration = (() => { }); const opts: TracingChannelLifeCycleOptions = { - deferSpanEnd({ span, data }) { + deferSpanEnd({ span, data, end }) { const normalizedRequest = data._sentryNormalizedRequest; const requestMetadata = data._sentryRequestMetadata; if (!normalizedRequest) { @@ -166,7 +174,21 @@ const _awsChannelIntegration = (() => { // 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; + if (requestMetadata?.isStream && !failed) { + return true; + } + + // Normally the region settles long before `send` does (the SDK awaits it internally to + // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span + // open until the region backfill lands. The error status was already applied by the + // helper's `error` subscriber, so a plain `end()` suffices. + const region = data._sentryRegion; + if (region && !region.settled) { + void region.promise.then(() => end()); + return true; + } + + return false; }, }; From 88ce281f46c17de7d2dd7290607a45c0a200661f Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 00:13:45 +0200 Subject: [PATCH 06/13] Read extendedRequestId off the error object with metadata fallback --- .../integrations/tracing-channel/aws-sdk/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index e6527b278eb9..993bef8cf762 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -156,8 +156,18 @@ const _awsChannelIntegration = (() => { safe(() => { if (failed) { - const err = data.error as { $metadata?: Record; RequestId?: string } | undefined; - setMetadataAttributes(span, { requestId: err?.RequestId, ...err?.$metadata }); + const err = data.error as + | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } + | undefined; + const errMetadata = err?.$metadata; + // Like the OTel path, read RequestId/extendedRequestId off the error itself, with + // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't + // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. + setMetadataAttributes(span, { + requestId: err?.RequestId ?? errMetadata?.requestId, + httpStatusCode: errMetadata?.httpStatusCode, + extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, + }); return; } From 7f7a6646bfa64cb1e3a7e8f2253dacda51420f0b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 00:31:12 +0200 Subject: [PATCH 07/13] Use CLOUD_REGION from @sentry/conventions --- .../src/integrations/tracing-channel/aws-sdk/constants.ts | 6 +++--- .../src/integrations/tracing-channel/aws-sdk/index.ts | 4 ++-- .../src/integrations/tracing-channel/aws-sdk/utils.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) 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 index 27d886365ab5..cc4e5905c3c9 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -4,14 +4,14 @@ * 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. + * Attributes that exist in `@sentry/conventions/attributes` are imported from there instead; + * TODO(aws-sdk): the active attributes below are being added to sentry-conventions and should move + * to `@sentry/conventions/attributes` imports once a release containing them ships. */ /** 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'; 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 index 993bef8cf762..49f4f19eea42 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -8,12 +8,12 @@ import { startInactiveSpan, waitForTracingChannelBinding, } from '@sentry/core'; -import { HTTP_STATUS_CODE } from '@sentry/conventions/attributes'; +import { CLOUD_REGION, 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 { AWS_REQUEST_EXTENDED_ID, AWS_REQUEST_ID, AWS_SDK_ORIGIN } from './constants'; import { ServicesExtensions } from './services'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; 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 index 4af89127f6f4..997d87acaa4f 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -1,5 +1,5 @@ -import { RPC_METHOD, RPC_SERVICE } from '@sentry/conventions/attributes'; -import { ATTR_RPC_SYSTEM, CLOUD_REGION } from './constants'; +import { CLOUD_REGION, RPC_METHOD, RPC_SERVICE } from '@sentry/conventions/attributes'; +import { ATTR_RPC_SYSTEM } from './constants'; import type { NormalizedRequest } from './types'; export function removeSuffixFromStringIfExists(str: string, suffixToRemove: string): string { From 396afe885b6e8d91a0cffe030a5195ed3a4f6516 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 00:44:19 +0200 Subject: [PATCH 08/13] Use underscore in span origin: auto.aws.orchestrion.aws_sdk Span origins only allow letters, digits, underscores, and dots per segment; matches the auto.ai.orchestrion.google_genai precedent. --- .../src/integrations/tracing-channel/aws-sdk/constants.ts | 2 +- .../src/integrations/tracing-channel/aws-sdk/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index cc4e5905c3c9..4e7593d5f1d2 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -10,7 +10,7 @@ */ /** 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 AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk'; export const ATTR_RPC_SYSTEM = 'rpc.system'; export const AWS_REQUEST_ID = 'aws.request.id'; 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 index 49f4f19eea42..8d406afef532 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -232,7 +232,7 @@ const _awsChannelIntegration = (() => { * 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 — + * `auto.aws.orchestrion.aws_sdk` origin). Requires the orchestrion runtime hook or bundler plugin — * wire it up via `experimentalUseDiagnosticsChannelInjection()`. * * @experimental From aeb0d733551326b6777a7cd2e906db3496948fcd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 00:54:51 +0200 Subject: [PATCH 09/13] Guard region provider call against synchronous throws The span is already started at that point; a sync throw bubbling into the enclosing safe wrapper would discard the span without ending it, leaking an open span for that AWS call. --- .../integrations/tracing-channel/aws-sdk/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 8d406afef532..9a0b1c425de2 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -121,8 +121,18 @@ const _awsChannelIntegration = (() => { // the traced call the way the OTel middleware does). Backfill it onto the span and the // normalized request once available; `deferSpanEnd` holds the span open until this settles // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). + // + // The provider call is guarded separately: the span is already started, so a synchronous + // throw bubbling into the enclosing `safe` would discard it without ending it (a leaked + // open span). + let regionResult: string | Promise | undefined; + try { + regionResult = clientConfig?.region?.(); + } catch { + // Nothing to do; continue without a region. + } const regionHolder = { settled: false, promise: Promise.resolve() }; - regionHolder.promise = Promise.resolve(clientConfig?.region?.()) + regionHolder.promise = Promise.resolve(regionResult) .then(region => { if (region) { normalizedRequest.region = region; From f34d4bdf51bd113e89fe6588fd3602e0f9a96282 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 01:01:55 +0200 Subject: [PATCH 10/13] Trace commands constructed without an input object Commands with all-optional members can be constructed without an input (e.g. new ListBucketsCommand()); the OTel path traces those, so default the command input to an empty object instead of skipping the span. All service extensions already read the input defensively. --- .../src/integrations/tracing-channel/aws-sdk/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 9a0b1c425de2..33f6c1915442 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -84,9 +84,8 @@ const _awsChannelIntegration = (() => { 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) { + if (!command || !commandName) { // Not a recognizable v3 command call; leave the active context untouched. return undefined; } @@ -98,7 +97,9 @@ const _awsChannelIntegration = (() => { // 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); + // Commands with all-optional members can be constructed without an input (`new + // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input ?? {}, undefined); const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); const span = startInactiveSpan({ From cf21df76936f086de0cec850d7900ffe9e3965e5 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 09:37:17 +0200 Subject: [PATCH 11/13] Document and narrow any usage in core aws-sdk files --- .../src/integrations/tracing-channel/aws-sdk/index.ts | 4 ++++ .../src/integrations/tracing-channel/aws-sdk/types.ts | 3 +++ .../src/integrations/tracing-channel/aws-sdk/utils.ts | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) 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 index 33f6c1915442..25959f753eb2 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -54,6 +54,8 @@ function safe(fn: () => T): T | undefined { } } +// `metadata` is smithy's `ResponseMetadata`, read off the untyped channel result/error (`any` for the +// same reason as `CommandInput`, see types.ts). function setMetadataAttributes(span: Span, metadata: Record | undefined): void { if (!metadata) { return; @@ -165,6 +167,8 @@ const _awsChannelIntegration = (() => { const failed = 'error' in data; + // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). safe(() => { if (failed) { const err = data.error as 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 index 493e9dfa15c8..93d1ebade30d 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -1,5 +1,7 @@ import type { SpanKindValue } from '@sentry/core'; +// Command inputs are service-specific shapes from hundreds of AWS APIs; typing them would require +// depending on the `@aws-sdk/*` client types. The per-service hooks read fields defensively instead. export type CommandInput = Record; /** @@ -14,6 +16,7 @@ export interface NormalizedRequest { } export interface NormalizedResponse { + // The command output, shaped per service/command (see `CommandInput` on why this isn't typed). data: any; request: NormalizedRequest; requestId?: 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 index 997d87acaa4f..4f8e771ba819 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -1,6 +1,6 @@ import { CLOUD_REGION, RPC_METHOD, RPC_SERVICE } from '@sentry/conventions/attributes'; import { ATTR_RPC_SYSTEM } from './constants'; -import type { NormalizedRequest } from './types'; +import type { CommandInput, NormalizedRequest } from './types'; export function removeSuffixFromStringIfExists(str: string, suffixToRemove: string): string { const suffixLength = suffixToRemove.length; @@ -10,7 +10,7 @@ export function removeSuffixFromStringIfExists(str: string, suffixToRemove: stri export function normalizeV3Request( serviceName: string, commandNameWithSuffix: string, - commandInput: Record, + commandInput: CommandInput, region: string | undefined, ): NormalizedRequest { return { From 828e59f1af94bca84879f462b2b1500cacdf8c4a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Sun, 12 Jul 2026 00:35:01 +0200 Subject: [PATCH 12/13] Simplify region holder initialization and drop dead lint disables --- .oxlintrc.base.json | 4 +-- .../tracing-channel/aws-sdk/index.ts | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index aba1665cd578..3d8ef2e1657c 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -152,10 +152,8 @@ "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" + "complexity": "off" } }, { 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 index 25959f753eb2..20fa8d005359 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -134,20 +134,23 @@ const _awsChannelIntegration = (() => { } catch { // Nothing to do; continue without a region. } - const regionHolder = { settled: false, promise: Promise.resolve() }; - regionHolder.promise = Promise.resolve(regionResult) - .then(region => { - if (region) { - normalizedRequest.region = region; - span.setAttribute(CLOUD_REGION, region); - } - }) - .catch(() => { - // Nothing to do; continue without a region. - }) - .finally(() => { - regionHolder.settled = true; - }); + // The `.finally` self-reference is safe: the callback only runs after initialization. + const regionHolder: { settled: boolean; promise: Promise } = { + settled: false, + promise: Promise.resolve(regionResult) + .then(region => { + if (region) { + normalizedRequest.region = region; + span.setAttribute(CLOUD_REGION, region); + } + }) + .catch(() => { + // Nothing to do; continue without a region. + }) + .finally(() => { + regionHolder.settled = true; + }), + }; data._sentryRegion = regionHolder; // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before From 6a7d2f77c73407fb646be275f2dd1175bfe5d8eb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 13 Jul 2026 11:24:16 +0200 Subject: [PATCH 13/13] Attach defaulted command input back onto the command Service hooks mutate commandInput (propagation headers, MessageAttributeNames, ClientContext); with a detached default those writes never reached the serialized request for input-less commands on older smithy clients. --- .../src/integrations/tracing-channel/aws-sdk/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 index 20fa8d005359..49d293920fc3 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -101,7 +101,14 @@ const _awsChannelIntegration = (() => { // Commands with all-optional members can be constructed without an input (`new // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. - const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input ?? {}, undefined); + // The default is assigned back onto the command (not kept detached) because service hooks + // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those + // writes must reach the serialized request. Current smithy clients already default `input` + // to `{}` in the command constructor; this only affects older clients in our range. + if (!command.input) { + command.input = {}; + } + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); const span = startInactiveSpan({