From e3f57f74d3f955a4ba12a9d9d0bd206dcb2856ab Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 16:38:59 +0200 Subject: [PATCH 1/5] Extract shared makeSafeSpanBuilder from graphql/aws-sdk safe helpers --- .../tracing-channel/aws-sdk/index.ts | 12 ++---------- .../tracing-channel/graphql/index.ts | 19 +++---------------- packages/server-utils/src/tracing-channel.ts | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 26 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 25959f753eb2..526c019d10b3 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 @@ -12,7 +12,7 @@ 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 { bindTracingChannelToSpan, makeSafeSpanBuilder } 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'; @@ -44,15 +44,7 @@ interface AwsV3Command { 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; - } -} +const safe = makeSafeSpanBuilder('[orchestrion:aws-sdk]'); // `metadata` is smithy's `ResponseMetadata`, read off the untyped channel result/error (`any` for the // same reason as `CommandInput`, see types.ts). diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 2a3baa669e16..294db1550722 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,11 +1,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; -import { DEBUG_BUILD } from '../../../debug-build'; +import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; import { CHANNELS } from '../../../orchestrion/channels'; -import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { bindTracingChannelToSpan, makeSafeSpanBuilder } from '../../../tracing-channel'; import { finalizeExecuteSpan, finalizeValidateSpan, @@ -35,19 +34,7 @@ function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): Grap }; } -/** - * Runs a span-building callback so a throw inside it can never break the user's graphql call: these - * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` - * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. - */ -function safe(fn: () => T): T | undefined { - try { - return fn(); - } catch (error) { - DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); - return undefined; - } -} +const safe = makeSafeSpanBuilder('[orchestrion:graphql]'); const _graphqlChannelIntegration = ((options: GraphqlDiagnosticChannelsOptions = {}) => { const config = getOptionsWithDefaults(options); diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 097a3da6018a..56317f07943b 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -90,6 +90,24 @@ export interface TracingChannelBindingHandle { const NOOP = (): void => {}; +/** + * Creates a guard for span-building callbacks so a throw inside them can never break the user's + * traced call: they run inside the `tracingChannel(...).trace*` machinery wrapping the real function + * (as the `getSpan` producer / `beforeSpanEnd` handler / `deferSpanEnd` owner), where an unguarded + * throw would propagate into the traced call. `debugLabel` prefixes the DEBUG-only warning, e.g. + * `[orchestrion:graphql]`. + */ +export function makeSafeSpanBuilder(debugLabel: string): (fn: () => T) => T | undefined { + return fn => { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn(`${debugLabel} error building span`, error); + return undefined; + } + }; +} + /** * Bind a span and its lifecycle to a tracing channel so the span becomes the active async context * for the traced operation and is ended when the operation completes. From ae894900690f06a2d85eac9a3f806a517dd75e3e Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 16:46:50 +0200 Subject: [PATCH 2/5] Inline try/catch instead of safe helper abstraction Reverts the makeSafeSpanBuilder extraction (and the graphql change that came with it) in favor of plain try/catch blocks at the three guard sites. The post-span hook and region provider keep their own guards so a throw there cannot discard the already-started span via the outer catch. --- .../tracing-channel/aws-sdk/index.ts | 57 +++++++++++-------- .../tracing-channel/graphql/index.ts | 19 ++++++- packages/server-utils/src/tracing-channel.ts | 18 ------ 3 files changed, 50 insertions(+), 44 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 526c019d10b3..315faa17b04b 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 @@ -12,7 +12,7 @@ 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, makeSafeSpanBuilder } 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'; @@ -44,8 +44,6 @@ interface AwsV3Command { constructor?: { name?: string }; } -const safe = makeSafeSpanBuilder('[orchestrion:aws-sdk]'); - // `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 { @@ -75,8 +73,10 @@ const _awsChannelIntegration = (() => { return; } - const getSpan = (data: AwsSendChannelContext): Span | undefined => - safe(() => { + // Everything in here (and in `deferSpanEnd` below) runs inside the tracingChannel machinery + // wrapping the user's `send` call, so a throw must never escape: it would break the AWS call. + const getSpan = (data: AwsSendChannelContext): Span | undefined => { + try { const command = data.arguments[0] as AwsV3Command | undefined; const commandName = command?.constructor?.name; if (!command || !commandName) { @@ -118,7 +118,7 @@ const _awsChannelIntegration = (() => { // 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 + // throw bubbling into the enclosing catch would discard it without ending it (a leaked // open span). let regionResult: string | Promise | undefined; try { @@ -143,11 +143,20 @@ const _awsChannelIntegration = (() => { 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)); + // `send` proceeds, so the mutated `commandInput` is used to build the request. Guarded + // separately so a throw can't discard the already-started span via the outer catch. + try { + servicesExtensions.requestPostSpanHook(normalizedRequest, span); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error in request post-span hook', error); + } return span; - }); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error building span', error); + return undefined; + } + }; const opts: TracingChannelLifeCycleOptions = { deferSpanEnd({ span, data, end }) { @@ -160,8 +169,9 @@ 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(() => { + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). Guarded so an + // enrichment throw can't escape into the user's `send` (see `getSpan`). + try { if (failed) { const err = data.error as | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } @@ -175,19 +185,20 @@ const _awsChannelIntegration = (() => { httpStatusCode: errMetadata?.httpStatusCode, extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, }); - return; - } - - const output = data.result as { $metadata?: Record } | undefined; - setMetadataAttributes(span, output?.$metadata); + } else { + 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); - }); + const normalizedResponse: NormalizedResponse = { + data: output, + request: normalizedRequest, + requestId: output?.$metadata?.requestId, + }; + servicesExtensions.responseHook(normalizedResponse, span); + } + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error enriching span', error); + } // 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. diff --git a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts index 294db1550722..2a3baa669e16 100644 --- a/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/graphql/index.ts @@ -1,10 +1,11 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { debug, defineIntegration, extendIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; import { graphqlIntegration as graphqlNativeIntegration } from '../../../graphql'; import type { GraphqlDiagnosticChannelsOptions } from '../../../graphql/graphql-dc-subscriber'; import { CHANNELS } from '../../../orchestrion/channels'; -import { bindTracingChannelToSpan, makeSafeSpanBuilder } from '../../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; import { finalizeExecuteSpan, finalizeValidateSpan, @@ -34,7 +35,19 @@ function getOptionsWithDefaults(options: GraphqlDiagnosticChannelsOptions): Grap }; } -const safe = makeSafeSpanBuilder('[orchestrion:graphql]'); +/** + * Runs a span-building callback so a throw inside it can never break the user's graphql call: these + * run inside the `tracingChannel(...).trace*` machinery wrapping the real function (as the `getSpan` + * producer / `beforeSpanEnd` handler), where an unguarded throw would propagate into the traced call. + */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:graphql] error building span', error); + return undefined; + } +} const _graphqlChannelIntegration = ((options: GraphqlDiagnosticChannelsOptions = {}) => { const config = getOptionsWithDefaults(options); diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 56317f07943b..097a3da6018a 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -90,24 +90,6 @@ export interface TracingChannelBindingHandle { const NOOP = (): void => {}; -/** - * Creates a guard for span-building callbacks so a throw inside them can never break the user's - * traced call: they run inside the `tracingChannel(...).trace*` machinery wrapping the real function - * (as the `getSpan` producer / `beforeSpanEnd` handler / `deferSpanEnd` owner), where an unguarded - * throw would propagate into the traced call. `debugLabel` prefixes the DEBUG-only warning, e.g. - * `[orchestrion:graphql]`. - */ -export function makeSafeSpanBuilder(debugLabel: string): (fn: () => T) => T | undefined { - return fn => { - try { - return fn(); - } catch (error) { - DEBUG_BUILD && debug.warn(`${debugLabel} error building span`, error); - return undefined; - } - }; -} - /** * Bind a span and its lifecycle to a tracing channel so the span becomes the active async context * for the traced operation and is ended when the operation completes. From 9591440e0cd17d70d1a54edc1407de3c11bd4ebb Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:16:54 +0200 Subject: [PATCH 3/5] feat(server-utils): Port S3, Kinesis, DynamoDB, SecretsManager and StepFunctions aws-sdk extensions Ports the attribute-only service extensions from the OTel aws-sdk integration to the orchestrion channel integration and registers them in the service registry: - S3: `aws.s3.bucket` - Kinesis: `aws.kinesis.stream.name` - DynamoDB: `db.*` and `aws.dynamodb.*` request/response attributes - SecretsManager: `aws.secretsmanager.secret.arn` (request and response) - StepFunctions: state machine / activity ARNs Straight ports; none of these inject trace propagation or change span lifecycle. Part of #20946 Co-Authored-By: Claude Fable 5 --- .../tracing-channel/aws-sdk/constants.ts | 31 +++ .../aws-sdk/services/ServicesExtensions.ts | 10 + .../aws-sdk/services/dynamodb.ts | 184 ++++++++++++++++++ .../aws-sdk/services/kinesis.ts | 20 ++ .../tracing-channel/aws-sdk/services/s3.ts | 20 ++ .../aws-sdk/services/secretsmanager.ts | 27 +++ .../aws-sdk/services/stepfunctions.ts | 25 +++ 7 files changed, 317 insertions(+) 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/kinesis.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/stepfunctions.ts 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 4e7593d5f1d2..9ec240793f12 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 @@ -15,3 +15,34 @@ 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'; +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 / StepFunctions +export const ATTR_AWS_SECRETSMANAGER_SECRET_ARN = 'aws.secretsmanager.secret.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'; 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 2cc308150307..fa67d828c957 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 @@ -1,6 +1,11 @@ import type { Span } from '@sentry/core'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import { DynamodbServiceExtension } from './dynamodb'; +import { KinesisServiceExtension } from './kinesis'; +import { S3ServiceExtension } from './s3'; +import { SecretsManagerServiceExtension } from './secretsmanager'; import type { ServiceExtension } from './ServiceExtension'; +import { StepFunctionsServiceExtension } from './stepfunctions'; export class ServicesExtensions implements ServiceExtension { private _services: Map; @@ -9,6 +14,11 @@ export class ServicesExtensions implements ServiceExtension { // 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(); + this._services.set('SecretsManager', new SecretsManagerServiceExtension()); + this._services.set('SFN', new StepFunctionsServiceExtension()); + this._services.set('DynamoDB', new DynamodbServiceExtension()); + this._services.set('S3', new S3ServiceExtension()); + this._services.set('Kinesis', new KinesisServiceExtension()); } public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { 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/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/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/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, + }; + } +} From 773a988346c97b9e92eccf0136fa82a07c9b6e98 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 9 Jul 2026 23:33:30 +0200 Subject: [PATCH 4/5] Set db op on DynamoDB aws-sdk spans --- .../integrations/tracing-channel/aws-sdk/services/dynamodb.ts | 2 ++ 1 file changed, 2 insertions(+) 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 index c735309e505f..4a04c4adf311 100644 --- 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 @@ -151,6 +151,8 @@ export class DynamodbServiceExtension implements ServiceExtension { return { spanAttributes, spanKind: SPAN_KIND.CLIENT, + // Matches what the exporter infers from `db.system` for the OTel DynamoDB spans. + spanOp: 'db', }; } From 50ce0001f07da05835f1e795ac773108db177eec Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 10 Jul 2026 09:37:46 +0200 Subject: [PATCH 5/5] Use unknown for stringified DynamoDB values --- .../tracing-channel/aws-sdk/services/dynamodb.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 4a04c4adf311..d74d47fc98a2 100644 --- 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 @@ -79,13 +79,13 @@ export class DynamodbServiceExtension implements ServiceExtension { if (normalizedRequest.commandInput?.GlobalSecondaryIndexes) { spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES] = toArray( normalizedRequest.commandInput.GlobalSecondaryIndexes, - ).map((x: Record) => JSON.stringify(x)); + ).map((x: unknown) => JSON.stringify(x)); } if (normalizedRequest.commandInput?.LocalSecondaryIndexes) { spanAttributes[ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES] = toArray( normalizedRequest.commandInput.LocalSecondaryIndexes, - ).map((x: Record) => JSON.stringify(x)); + ).map((x: unknown) => JSON.stringify(x)); } } @@ -138,13 +138,13 @@ export class DynamodbServiceExtension implements ServiceExtension { if (normalizedRequest.commandInput?.AttributeDefinitions) { spanAttributes[ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS] = toArray( normalizedRequest.commandInput.AttributeDefinitions, - ).map((x: Record) => JSON.stringify(x)); + ).map((x: unknown) => 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)); + ).map((x: unknown) => JSON.stringify(x)); } } @@ -160,14 +160,14 @@ export class DynamodbServiceExtension implements ServiceExtension { if (response.data?.ConsumedCapacity) { span.setAttribute( ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, - toArray(response.data.ConsumedCapacity).map((x: Record) => JSON.stringify(x)), + toArray(response.data.ConsumedCapacity).map((x: unknown) => 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)), + toArray(response.data.ItemCollectionMetrics).map((x: unknown) => JSON.stringify(x)), ); }