|
| 1 | +import * as diagnosticsChannel from 'node:diagnostics_channel'; |
| 2 | +import type { IntegrationFn, Span } from '@sentry/core'; |
| 3 | +import { |
| 4 | + debug, |
| 5 | + defineIntegration, |
| 6 | + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, |
| 7 | + SPAN_KIND, |
| 8 | + startInactiveSpan, |
| 9 | + waitForTracingChannelBinding, |
| 10 | +} from '@sentry/core'; |
| 11 | +import { |
| 12 | + _AWS_REQUEST_ID as AWS_REQUEST_ID, |
| 13 | + AWS_REQUEST_EXTENDED_ID, |
| 14 | + CLOUD_REGION, |
| 15 | + HTTP_STATUS_CODE, |
| 16 | +} from '@sentry/conventions/attributes'; |
| 17 | +import { DEBUG_BUILD } from '../../../debug-build'; |
| 18 | +import { CHANNELS } from '../../../orchestrion/channels'; |
| 19 | +import type { TracingChannelLifeCycleOptions } from '../../../tracing-channel'; |
| 20 | +import { bindTracingChannelToSpan } from '../../../tracing-channel'; |
| 21 | +import { AWS_SDK_ORIGIN } from './constants'; |
| 22 | +import { ServicesExtensions } from './services'; |
| 23 | +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; |
| 24 | +import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; |
| 25 | + |
| 26 | +// Same name as the OTel `Aws` integration by design, so enabling injection swaps this in for it. |
| 27 | +const INTEGRATION_NAME = 'Aws' as const; |
| 28 | + |
| 29 | +// The context orchestrion's transform attaches to the channel: `arguments` is the live args of the |
| 30 | +// wrapped `Client.prototype.send` call (`[command, ...]`), `self` the client, `result`/`error` the |
| 31 | +// settled value. The `_sentry*` fields are stashed by us across the call's lifecycle. |
| 32 | +interface AwsSendChannelContext { |
| 33 | + arguments: unknown[]; |
| 34 | + self?: { config?: AwsClientConfig; constructor?: { name?: string } }; |
| 35 | + result?: unknown; |
| 36 | + error?: unknown; |
| 37 | + _sentryNormalizedRequest?: NormalizedRequest; |
| 38 | + _sentryRequestMetadata?: RequestMetadata; |
| 39 | + _sentryRegion?: { settled: boolean; promise: Promise<void> }; |
| 40 | +} |
| 41 | + |
| 42 | +interface AwsClientConfig { |
| 43 | + serviceId?: string; |
| 44 | + region?: () => string | Promise<string> | undefined; |
| 45 | +} |
| 46 | + |
| 47 | +interface AwsV3Command { |
| 48 | + input?: Record<string, unknown>; |
| 49 | + constructor?: { name?: string }; |
| 50 | +} |
| 51 | + |
| 52 | +/** Runs a span-building callback so a throw inside it can never break the user's aws-sdk call. */ |
| 53 | +function safe<T>(fn: () => T): T | undefined { |
| 54 | + try { |
| 55 | + return fn(); |
| 56 | + } catch (error) { |
| 57 | + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error building span', error); |
| 58 | + return undefined; |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +// `metadata` is smithy's `ResponseMetadata`, read off the untyped channel result/error (`any` for the |
| 63 | +// same reason as `CommandInput`, see types.ts). |
| 64 | +function setMetadataAttributes(span: Span, metadata: Record<string, any> | undefined): void { |
| 65 | + if (!metadata) { |
| 66 | + return; |
| 67 | + } |
| 68 | + if (metadata.requestId) { |
| 69 | + // oxlint-disable-next-line typescript/no-deprecated |
| 70 | + span.setAttribute(AWS_REQUEST_ID, metadata.requestId); |
| 71 | + } |
| 72 | + if (metadata.httpStatusCode) { |
| 73 | + // oxlint-disable-next-line typescript/no-deprecated |
| 74 | + span.setAttribute(HTTP_STATUS_CODE, metadata.httpStatusCode); |
| 75 | + } |
| 76 | + if (metadata.extendedRequestId) { |
| 77 | + // oxlint-disable-next-line typescript/no-deprecated |
| 78 | + span.setAttribute(AWS_REQUEST_EXTENDED_ID, metadata.extendedRequestId); |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +const _awsChannelIntegration = (() => { |
| 83 | + const servicesExtensions = new ServicesExtensions(); |
| 84 | + |
| 85 | + return { |
| 86 | + name: INTEGRATION_NAME, |
| 87 | + setupOnce() { |
| 88 | + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. |
| 89 | + if (!diagnosticsChannel.tracingChannel) { |
| 90 | + return; |
| 91 | + } |
| 92 | + |
| 93 | + const getSpan = (data: AwsSendChannelContext): Span | undefined => |
| 94 | + safe(() => { |
| 95 | + const command = data.arguments[0] as AwsV3Command | undefined; |
| 96 | + const commandName = command?.constructor?.name; |
| 97 | + if (!command || !commandName) { |
| 98 | + // Not a recognizable v3 command call; leave the active context untouched. |
| 99 | + return undefined; |
| 100 | + } |
| 101 | + |
| 102 | + const clientConfig = data.self?.config; |
| 103 | + const serviceName = |
| 104 | + clientConfig?.serviceId ?? |
| 105 | + // `clientName` isn't available at the `send` boundary; fall back to the client's |
| 106 | + // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. |
| 107 | + removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); |
| 108 | + |
| 109 | + // Commands with all-optional members can be constructed without an input (`new |
| 110 | + // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. |
| 111 | + // The default is assigned back onto the command (not kept detached) because service hooks |
| 112 | + // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those |
| 113 | + // writes must reach the serialized request. Current smithy clients already default `input` |
| 114 | + // to `{}` in the command constructor; this only affects older clients in our range. |
| 115 | + if (!command.input) { |
| 116 | + command.input = {}; |
| 117 | + } |
| 118 | + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); |
| 119 | + const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); |
| 120 | + |
| 121 | + const span = startInactiveSpan({ |
| 122 | + name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, |
| 123 | + kind: requestMetadata.spanKind ?? SPAN_KIND.CLIENT, |
| 124 | + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; |
| 125 | + // service extensions override it where inference yields a different op (DynamoDB: `db`). |
| 126 | + op: requestMetadata.spanOp || 'rpc', |
| 127 | + attributes: { |
| 128 | + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, |
| 129 | + ...extractAttributesFromNormalizedRequest(normalizedRequest), |
| 130 | + ...requestMetadata.spanAttributes, |
| 131 | + }, |
| 132 | + }); |
| 133 | + |
| 134 | + data._sentryNormalizedRequest = normalizedRequest; |
| 135 | + data._sentryRequestMetadata = requestMetadata; |
| 136 | + |
| 137 | + // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay |
| 138 | + // the traced call the way the OTel middleware does). Backfill it onto the span and the |
| 139 | + // normalized request once available; `deferSpanEnd` holds the span open until this settles |
| 140 | + // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). |
| 141 | + // |
| 142 | + // The provider call is guarded separately: the span is already started, so a synchronous |
| 143 | + // throw bubbling into the enclosing `safe` would discard it without ending it (a leaked |
| 144 | + // open span). |
| 145 | + let regionResult: string | Promise<string> | undefined; |
| 146 | + try { |
| 147 | + regionResult = clientConfig?.region?.(); |
| 148 | + } catch { |
| 149 | + // Nothing to do; continue without a region. |
| 150 | + } |
| 151 | + // The `.finally` self-reference is safe: the callback only runs after initialization. |
| 152 | + const regionHolder: { settled: boolean; promise: Promise<void> } = { |
| 153 | + settled: false, |
| 154 | + promise: Promise.resolve(regionResult) |
| 155 | + .then(region => { |
| 156 | + if (region) { |
| 157 | + normalizedRequest.region = region; |
| 158 | + span.setAttribute(CLOUD_REGION, region); |
| 159 | + } |
| 160 | + }) |
| 161 | + .catch(() => { |
| 162 | + // Nothing to do; continue without a region. |
| 163 | + }) |
| 164 | + .finally(() => { |
| 165 | + regionHolder.settled = true; |
| 166 | + }), |
| 167 | + }; |
| 168 | + data._sentryRegion = regionHolder; |
| 169 | + |
| 170 | + // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before |
| 171 | + // `send` proceeds, so the mutated `commandInput` is used to build the request. |
| 172 | + safe(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); |
| 173 | + |
| 174 | + return span; |
| 175 | + }); |
| 176 | + |
| 177 | + const opts: TracingChannelLifeCycleOptions<AwsSendChannelContext> = { |
| 178 | + deferSpanEnd({ span, data, end }) { |
| 179 | + const normalizedRequest = data._sentryNormalizedRequest; |
| 180 | + const requestMetadata = data._sentryRequestMetadata; |
| 181 | + if (!normalizedRequest) { |
| 182 | + return false; |
| 183 | + } |
| 184 | + |
| 185 | + const failed = 'error' in data; |
| 186 | + |
| 187 | + // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's |
| 188 | + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). |
| 189 | + safe(() => { |
| 190 | + if (failed) { |
| 191 | + const err = data.error as |
| 192 | + | { $metadata?: Record<string, any>; RequestId?: string; extendedRequestId?: string } |
| 193 | + | undefined; |
| 194 | + const errMetadata = err?.$metadata; |
| 195 | + // Like the OTel path, read RequestId/extendedRequestId off the error itself, with |
| 196 | + // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't |
| 197 | + // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. |
| 198 | + setMetadataAttributes(span, { |
| 199 | + requestId: err?.RequestId ?? errMetadata?.requestId, |
| 200 | + httpStatusCode: errMetadata?.httpStatusCode, |
| 201 | + extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, |
| 202 | + }); |
| 203 | + return; |
| 204 | + } |
| 205 | + |
| 206 | + const output = data.result as { $metadata?: Record<string, any> } | undefined; |
| 207 | + setMetadataAttributes(span, output?.$metadata); |
| 208 | + |
| 209 | + const normalizedResponse: NormalizedResponse = { |
| 210 | + data: output, |
| 211 | + request: normalizedRequest, |
| 212 | + requestId: output?.$metadata?.requestId, |
| 213 | + }; |
| 214 | + servicesExtensions.responseHook(normalizedResponse, span); |
| 215 | + }); |
| 216 | + |
| 217 | + // Streaming responses end the span when their wrapped stream is consumed (see |
| 218 | + // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. |
| 219 | + if (requestMetadata?.isStream && !failed) { |
| 220 | + return true; |
| 221 | + } |
| 222 | + |
| 223 | + // Normally the region settles long before `send` does (the SDK awaits it internally to |
| 224 | + // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span |
| 225 | + // open until the region backfill lands. The error status was already applied by the |
| 226 | + // helper's `error` subscriber, so a plain `end()` suffices. |
| 227 | + const region = data._sentryRegion; |
| 228 | + if (region && !region.settled) { |
| 229 | + void region.promise.then(() => end()); |
| 230 | + return true; |
| 231 | + } |
| 232 | + |
| 233 | + return false; |
| 234 | + }, |
| 235 | + }; |
| 236 | + |
| 237 | + // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the |
| 238 | + // transform injects one channel per package. Only the package hosting the app's client fires, so |
| 239 | + // subscribing to all of them is safe and never double-instruments a single call. |
| 240 | + const awsSendChannels = [ |
| 241 | + CHANNELS.AWS_SMITHY_CORE_SEND, |
| 242 | + CHANNELS.AWS_SMITHY_CLIENT_SEND, |
| 243 | + CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, |
| 244 | + ] as const; |
| 245 | + |
| 246 | + DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); |
| 247 | + |
| 248 | + waitForTracingChannelBinding(() => { |
| 249 | + for (const channelName of awsSendChannels) { |
| 250 | + bindTracingChannelToSpan( |
| 251 | + diagnosticsChannel.tracingChannel<AwsSendChannelContext>(channelName), |
| 252 | + getSpan, |
| 253 | + opts, |
| 254 | + ); |
| 255 | + } |
| 256 | + }); |
| 257 | + }, |
| 258 | + }; |
| 259 | +}) satisfies IntegrationFn; |
| 260 | + |
| 261 | +/** |
| 262 | + * EXPERIMENTAL — orchestrion-driven aws-sdk (v3) integration. |
| 263 | + * |
| 264 | + * Subscribes to the `orchestrion:@smithy/smithy-client:send` (and equivalent) diagnostics_channel |
| 265 | + * the orchestrion code transform injects into the AWS SDK's smithy `Client.prototype.send`, emitting |
| 266 | + * spans identical to the OTel `@opentelemetry/instrumentation-aws-sdk` integration (with a distinct |
| 267 | + * `auto.aws.orchestrion.aws_sdk` origin). Requires the orchestrion runtime hook or bundler plugin — |
| 268 | + * wire it up via `experimentalUseDiagnosticsChannelInjection()`. |
| 269 | + * |
| 270 | + * @experimental |
| 271 | + */ |
| 272 | +export const awsChannelIntegration = defineIntegration(_awsChannelIntegration); |
0 commit comments