Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,6 @@ 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<T>(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<string, any> | undefined): void {
Expand Down Expand Up @@ -83,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) {
Expand Down Expand Up @@ -126,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<string> | undefined;
try {
Expand All @@ -151,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<AwsSendChannelContext> = {
deferSpanEnd({ span, data, end }) {
Expand All @@ -168,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<string, any>; RequestId?: string; extendedRequestId?: string }
Expand All @@ -183,19 +185,20 @@ const _awsChannelIntegration = (() => {
httpStatusCode: errMetadata?.httpStatusCode,
extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId,
});
return;
}

const output = data.result as { $metadata?: Record<string, any> } | undefined;
setMetadataAttributes(span, output?.$metadata);
} else {
const output = data.result as { $metadata?: Record<string, any> } | 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, ServiceExtension>;
Expand All @@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feat PR lacks integration tests

Low Severity

This feature adds five AWS service span extensions and registers them in ServicesExtensions, but the diff includes no integration or E2E test asserting the new span attributes (similar to existing orchestrion channel tests for Postgres).

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 03cef18. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feature lacks integration tests

Low Severity

This feat change registers new S3, Kinesis, DynamoDB, Secrets Manager, and Step Functions span attribute hooks, but the diff adds no unit, integration, or E2E coverage asserting those attributes on spans. Per project testing conventions, a feature PR should include at least one test exercising the new behavior.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 0e20b44. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing integration or E2E tests

Medium Severity

This feat PR registers new AWS service span attribute extensions but the diff adds no unit, integration, or E2E test that exercises the orchestrion aws-sdk channel and asserts attributes such as aws.s3.bucket or db.* on emitted spans.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 979ee5c. Configure here.

Comment thread
cursor[bot] marked this conversation as resolved.
}

public requestPreSpanHook(request: NormalizedRequest): RequestMetadata {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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<T>(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<string, unknown> = {};

/* 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: unknown) => JSON.stringify(x));
}

if (normalizedRequest.commandInput?.LocalSecondaryIndexes) {
spanAttributes[ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES] = toArray(
normalizedRequest.commandInput.LocalSecondaryIndexes,
).map((x: unknown) => 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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scan segment zero skipped

Low Severity

For Scan, aws.dynamodb.segment is set only when commandInput.Segment is truthy, so parallel scans using segment 0 never get the segment attribute on the span.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e88a652. Configure here.


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: unknown) => JSON.stringify(x));
}

if (normalizedRequest.commandInput?.GlobalSecondaryIndexUpdates) {
spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES] = toArray(
normalizedRequest.commandInput.GlobalSecondaryIndexUpdates,
).map((x: unknown) => JSON.stringify(x));
}
}

return {
spanAttributes,
spanKind: SPAN_KIND.CLIENT,
// Matches what the exporter infers from `db.system` for the OTel DynamoDB spans.
spanOp: 'db',
};
}

public responseHook(response: NormalizedResponse, span: Span): void {
if (response.data?.ConsumedCapacity) {
span.setAttribute(
ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY,
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: unknown) => 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truthy checks skip DynamoDB attributes

Medium Severity

Several DynamoDB span attributes are only set inside truthy if checks, so valid values like ScanIndexForward: false, parallel Segment: 0, and response Count or ScannedCount of zero never appear on spans even when the API returned them.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03cef18. Configure here.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero counts omitted from spans

Low Severity

responseHook only sets aws.dynamodb.count and aws.dynamodb.scanned_count when response.data.Count and response.data.ScannedCount are truthy, so legitimate zero values from Query or Scan are never recorded on the span.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e88a652. Configure here.

}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feature lacks integration tests

Medium Severity

This feature adds several AWS service span attribute extensions but the diff includes no unit, integration, or E2E coverage. Per SDK testing conventions for feat PRs, at least one test should assert the new attributes on spans for services such as DynamoDB or S3.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit e88a652. Configure here.

Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

if (streamName) {
spanAttributes[AWS_KINESIS_STREAM_NAME] = streamName;
}

return {
spanAttributes,
spanKind: SPAN_KIND.CLIENT,
};
}
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

if (bucketName) {
spanAttributes[AWS_S3_BUCKET] = bucketName;
}

return {
spanAttributes,
spanKind: SPAN_KIND.CLIENT,
};
}
}
Loading
Loading