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..4e7593d5f1d2 --- /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. + * 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 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..25959f753eb2 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -0,0 +1,255 @@ +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 { 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 } 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; + _sentryRegion?: { settled: boolean; promise: Promise }; +} + +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; + } +} + +// `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; + } + 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 commandName = command?.constructor?.name; + if (!command || !commandName) { + // 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'); + + // 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({ + 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), + ...requestMetadata.spanAttributes, + }, + }); + + data._sentryNormalizedRequest = normalizedRequest; + data._sentryRequestMetadata = requestMetadata; + + // `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). + // + // 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(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 + // `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, end }) { + const normalizedRequest = data._sentryNormalizedRequest; + const requestMetadata = data._sentryRequestMetadata; + if (!normalizedRequest) { + return false; + } + + 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 + | { $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; + } + + 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. + 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; + }, + }; + + // 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..071f9005b8f0 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts @@ -0,0 +1,22 @@ +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. Unlike the OTel middleware patch, a tracing-channel + // 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; +} 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..2cc308150307 --- /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): void { + const serviceExtension = this._services.get(response.request.serviceName); + 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..93d1ebade30d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -0,0 +1,36 @@ +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; + +/** + * 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 { + // The command output, shaped per service/command (see `CommandInput` on why this isn't typed). + 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; + // Overrides the default `rpc` span op (e.g. `db` for DynamoDB). + spanOp?: 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..4f8e771ba819 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -0,0 +1,31 @@ +import { CLOUD_REGION, RPC_METHOD, RPC_SERVICE } from '@sentry/conventions/attributes'; +import { ATTR_RPC_SYSTEM } from './constants'; +import type { CommandInput, 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: CommandInput, + 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 3d56b43b29d8..bec38119b1d8 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,7 @@ import { redisChannels } from './config/redis'; import { expressChannels } from './config/express'; import { graphqlChannels } from './config/graphql'; import { kafkajsChannels } from './config/kafkajs'; +import { awsSdkChannels } from './config/aws-sdk'; /** * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. @@ -43,6 +44,7 @@ export const CHANNELS = { ...expressChannels, ...graphqlChannels, ...kafkajsChannels, + ...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 b0e4d3e8b6d1..4dead6822e99 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -15,6 +15,7 @@ import { redisConfig } from './redis'; import { expressConfig } from './express'; import { graphqlConfig } from './graphql'; import { kafkajsConfig } from './kafkajs'; +import { awsSdkConfig } from './aws-sdk'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, @@ -32,6 +33,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...expressConfig, ...graphqlConfig, ...kafkajsConfig, + ...awsSdkConfig, ]; /** 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;