Skip to content

Commit f024586

Browse files
andreiborzaclaude
andcommitted
feat(server-utils): Port SQS, SNS and Lambda aws-sdk extensions with trace propagation
Ports the messaging service extensions from the OTel aws-sdk integration to the orchestrion channel integration and registers them in the service registry: - SQS: producer/consumer span kinds and names, `messaging.*` attributes, trace propagation into outgoing `MessageAttributes` (single and batch), and span links from received messages via the propagated headers - SNS: producer span kind/name for `Publish`, `messaging.*` and topic ARN attributes, trace propagation into `MessageAttributes` - Lambda: `faas.*` attributes for `Invoke`, trace propagation into the base64 `ClientContext` (respecting the 3583 byte cap) Propagation writes Sentry-native `sentry-trace`/`baggage` headers derived from the request span instead of OTel `propagation.inject`, and the SQS receive side reads them back with `propagationContextFromHeaders`. Part of #20946 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0e20b44 commit f024586

7 files changed

Lines changed: 458 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* AWS SDK for JavaScript
3+
* Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
4+
*
5+
* This product includes software developed at
6+
* Amazon Web Services, Inc. (http://aws.amazon.com/).
7+
*/
8+
9+
/*
10+
These are slightly modified and simplified versions of the actual SQS/SNS types included
11+
in the official distribution:
12+
https://github.com/aws/aws-sdk-js/blob/master/clients/sqs.d.ts
13+
These are brought here to avoid having users install the `aws-sdk` whenever they
14+
require this instrumentation.
15+
*/
16+
17+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
18+
interface Blob {}
19+
type Binary = Buffer | Uint8Array | Blob | string;
20+
21+
// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location
22+
export namespace SNS {
23+
interface MessageAttributeValue {
24+
DataType: string;
25+
StringValue?: string;
26+
BinaryValue?: Binary;
27+
}
28+
29+
export type MessageAttributeMap = { [key: string]: MessageAttributeValue };
30+
}
31+
32+
// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location
33+
export namespace SQS {
34+
type StringList = string[];
35+
type BinaryList = Binary[];
36+
interface MessageAttributeValue {
37+
StringValue?: string;
38+
BinaryValue?: Binary;
39+
StringListValues?: StringList;
40+
BinaryListValues?: BinaryList;
41+
DataType: string;
42+
}
43+
44+
export type MessageBodyAttributeMap = {
45+
[key: string]: MessageAttributeValue;
46+
};
47+
48+
type MessageSystemAttributeMap = { [key: string]: string };
49+
50+
export interface Message {
51+
MessageId?: string;
52+
ReceiptHandle?: string;
53+
MD5OfBody?: string;
54+
Body?: string;
55+
Attributes?: MessageSystemAttributeMap;
56+
MD5OfMessageAttributes?: string;
57+
MessageAttributes?: MessageBodyAttributeMap;
58+
}
59+
}

packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,17 @@ export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb';
4646
export const ATTR_AWS_SECRETSMANAGER_SECRET_ARN = 'aws.secretsmanager.secret.arn';
4747
export const ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN = 'aws.step_functions.activity.arn';
4848
export const ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN = 'aws.step_functions.state_machine.arn';
49+
50+
// SNS
51+
export const ATTR_AWS_SNS_TOPIC_ARN = 'aws.sns.topic.arn';
52+
53+
// Lambda (faas)
54+
export const ATTR_FAAS_INVOKED_NAME = 'faas.invoked_name';
55+
export const ATTR_FAAS_INVOKED_PROVIDER = 'faas.invoked_provider';
56+
export const ATTR_FAAS_INVOKED_REGION = 'faas.invoked_region';
57+
export const ATTR_FAAS_EXECUTION = 'faas.execution';
58+
59+
// Messaging (obsolete OTel conventions kept for parity with the OTel integration)
60+
export const ATTR_MESSAGING_DESTINATION = 'messaging.destination';
61+
export const ATTR_MESSAGING_DESTINATION_KIND = 'messaging.destination_kind';
62+
export const MESSAGING_DESTINATION_KIND_VALUE_TOPIC = 'topic';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { Span } from '@sentry/core';
2+
import {
3+
debug,
4+
dynamicSamplingContextToSentryBaggageHeader,
5+
getDynamicSamplingContextFromSpan,
6+
spanToTraceHeader,
7+
} from '@sentry/core';
8+
import { DEBUG_BUILD } from '../../../../debug-build';
9+
import type { SNS, SQS } from '../aws-sdk.types';
10+
11+
// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html
12+
export const MAX_MESSAGE_ATTRIBUTES = 10;
13+
14+
// Sentry trace-propagation headers written into / read from AWS message attributes.
15+
const SENTRY_TRACE_HEADER = 'sentry-trace';
16+
const BAGGAGE_HEADER = 'baggage';
17+
const PROPAGATION_FIELDS = [SENTRY_TRACE_HEADER, BAGGAGE_HEADER];
18+
19+
export interface AwsSdkContextObject {
20+
[key: string]: {
21+
StringValue?: string;
22+
Value?: string;
23+
};
24+
}
25+
26+
/** Build the `sentry-trace`/`baggage` header pair carrying the span's trace context. */
27+
export function getPropagationHeaders(span: Span): Record<string, string> {
28+
const headers: Record<string, string> = {
29+
[SENTRY_TRACE_HEADER]: spanToTraceHeader(span),
30+
};
31+
const baggage = dynamicSamplingContextToSentryBaggageHeader(getDynamicSamplingContextFromSpan(span));
32+
if (baggage) {
33+
headers[BAGGAGE_HEADER] = baggage;
34+
}
35+
return headers;
36+
}
37+
38+
/**
39+
* Inject the span's trace-propagation headers into an SQS/SNS message-attribute map, so the consumer
40+
* can continue the trace. Respects the SQS 10-attribute quota. Mirrors the OTel integration's
41+
* `injectPropagationContext`, but writes Sentry's `sentry-trace`/`baggage` instead of W3C headers.
42+
*/
43+
export function injectPropagationContext(
44+
attributesMap: SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap | undefined,
45+
span: Span,
46+
): SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap {
47+
const attributes = attributesMap ?? {};
48+
const headers = getPropagationHeaders(span);
49+
const headerKeys = Object.keys(headers);
50+
51+
if (Object.keys(attributes).length + headerKeys.length <= MAX_MESSAGE_ATTRIBUTES) {
52+
for (const key of headerKeys) {
53+
(attributes as AwsSdkContextObject)[key] = { DataType: 'String', StringValue: headers[key] } as any;
54+
}
55+
} else {
56+
DEBUG_BUILD &&
57+
debug.warn(
58+
'[orchestrion:aws-sdk] cannot set trace propagation on SQS/SNS message due to maximum amount of MessageAttributes',
59+
);
60+
}
61+
return attributes;
62+
}
63+
64+
/** Read the propagation headers back off a received SQS message, if present. */
65+
export function extractPropagationHeaders(
66+
message: SQS.Message,
67+
): { sentryTrace?: string; baggage?: string } | undefined {
68+
const carrier = (message.MessageAttributes ?? {}) as AwsSdkContextObject;
69+
const sentryTrace = carrier[SENTRY_TRACE_HEADER]?.StringValue ?? carrier[SENTRY_TRACE_HEADER]?.Value;
70+
if (!sentryTrace) {
71+
return undefined;
72+
}
73+
return {
74+
sentryTrace,
75+
baggage: carrier[BAGGAGE_HEADER]?.StringValue ?? carrier[BAGGAGE_HEADER]?.Value,
76+
};
77+
}
78+
79+
export function addPropagationFieldsToAttributeNames(messageAttributeNames: string[] = []): string[] {
80+
return messageAttributeNames.length
81+
? Array.from(new Set([...messageAttributeNames, ...PROPAGATION_FIELDS]))
82+
: [...PROPAGATION_FIELDS];
83+
}

packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import type { Span } from '@sentry/core';
22
import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types';
33
import { DynamodbServiceExtension } from './dynamodb';
44
import { KinesisServiceExtension } from './kinesis';
5+
import { LambdaServiceExtension } from './lambda';
56
import { S3ServiceExtension } from './s3';
67
import { SecretsManagerServiceExtension } from './secretsmanager';
78
import type { ServiceExtension } from './ServiceExtension';
9+
import { SnsServiceExtension } from './sns';
10+
import { SqsServiceExtension } from './sqs';
811
import { StepFunctionsServiceExtension } from './stepfunctions';
912

1013
export class ServicesExtensions implements ServiceExtension {
@@ -16,7 +19,10 @@ export class ServicesExtensions implements ServiceExtension {
1619
this._services = new Map();
1720
this._services.set('SecretsManager', new SecretsManagerServiceExtension());
1821
this._services.set('SFN', new StepFunctionsServiceExtension());
22+
this._services.set('SQS', new SqsServiceExtension());
23+
this._services.set('SNS', new SnsServiceExtension());
1924
this._services.set('DynamoDB', new DynamodbServiceExtension());
25+
this._services.set('Lambda', new LambdaServiceExtension());
2026
this._services.set('S3', new S3ServiceExtension());
2127
this._services.set('Kinesis', new KinesisServiceExtension());
2228
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Span } from '@sentry/core';
2+
import { debug, SPAN_KIND } from '@sentry/core';
3+
import { DEBUG_BUILD } from '../../../../debug-build';
4+
import {
5+
ATTR_FAAS_EXECUTION,
6+
ATTR_FAAS_INVOKED_NAME,
7+
ATTR_FAAS_INVOKED_PROVIDER,
8+
ATTR_FAAS_INVOKED_REGION,
9+
} from '../constants';
10+
import type { NormalizedRequest, NormalizedResponse } from '../types';
11+
import { getPropagationHeaders } from './MessageAttributes';
12+
import type { RequestMetadata, ServiceExtension } from './ServiceExtension';
13+
14+
const INVOKE_COMMAND = 'Invoke';
15+
16+
export class LambdaServiceExtension implements ServiceExtension {
17+
public requestPreSpanHook(request: NormalizedRequest): RequestMetadata {
18+
const functionName = request.commandInput?.FunctionName;
19+
20+
let spanAttributes: Record<string, unknown> = {};
21+
let spanName: string | undefined;
22+
23+
switch (request.commandName) {
24+
case INVOKE_COMMAND:
25+
spanAttributes = {
26+
[ATTR_FAAS_INVOKED_NAME]: functionName,
27+
[ATTR_FAAS_INVOKED_PROVIDER]: 'aws',
28+
};
29+
if (request.region) {
30+
spanAttributes[ATTR_FAAS_INVOKED_REGION] = request.region;
31+
}
32+
spanName = `${functionName} ${INVOKE_COMMAND}`;
33+
break;
34+
}
35+
return {
36+
spanAttributes,
37+
spanKind: SPAN_KIND.CLIENT,
38+
spanName,
39+
};
40+
}
41+
42+
public requestPostSpanHook(request: NormalizedRequest, span: Span): void {
43+
if (request.commandName === INVOKE_COMMAND && request.commandInput) {
44+
request.commandInput.ClientContext = injectLambdaPropagationContext(request.commandInput.ClientContext, span);
45+
}
46+
}
47+
48+
public responseHook(response: NormalizedResponse, span: Span): void {
49+
if (response.request.commandName === INVOKE_COMMAND) {
50+
span.setAttribute(ATTR_FAAS_EXECUTION, response.requestId);
51+
}
52+
}
53+
}
54+
55+
function injectLambdaPropagationContext(clientContext: string | undefined, span: Span): string | undefined {
56+
try {
57+
const propagatedContext = getPropagationHeaders(span);
58+
59+
const parsedClientContext = clientContext ? JSON.parse(Buffer.from(clientContext, 'base64').toString('utf8')) : {};
60+
61+
const updatedClientContext = {
62+
...parsedClientContext,
63+
custom: {
64+
...parsedClientContext.custom,
65+
...propagatedContext,
66+
},
67+
};
68+
69+
const encodedClientContext = Buffer.from(JSON.stringify(updatedClientContext)).toString('base64');
70+
71+
// The length of client context is capped at 3583 bytes of base64 encoded data
72+
// (https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_RequestSyntax)
73+
if (encodedClientContext.length > 3583) {
74+
DEBUG_BUILD &&
75+
debug.warn(
76+
'[orchestrion:aws-sdk] cannot set trace propagation on lambda invoke parameters due to ClientContext length limitations.',
77+
);
78+
return clientContext;
79+
}
80+
81+
return encodedClientContext;
82+
} catch (e) {
83+
DEBUG_BUILD && debug.log('[orchestrion:aws-sdk] failed to set trace propagation on lambda ClientContext', e);
84+
return clientContext;
85+
}
86+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Span, SpanKindValue } from '@sentry/core';
2+
import { SPAN_KIND } from '@sentry/core';
3+
import { MESSAGING_DESTINATION_NAME, MESSAGING_SYSTEM } from '@sentry/conventions/attributes';
4+
import {
5+
ATTR_AWS_SNS_TOPIC_ARN,
6+
ATTR_MESSAGING_DESTINATION,
7+
ATTR_MESSAGING_DESTINATION_KIND,
8+
MESSAGING_DESTINATION_KIND_VALUE_TOPIC,
9+
} from '../constants';
10+
import type { NormalizedRequest, NormalizedResponse } from '../types';
11+
import { injectPropagationContext } from './MessageAttributes';
12+
import type { RequestMetadata, ServiceExtension } from './ServiceExtension';
13+
14+
export class SnsServiceExtension implements ServiceExtension {
15+
public requestPreSpanHook(request: NormalizedRequest): RequestMetadata {
16+
let spanKind: SpanKindValue = SPAN_KIND.CLIENT;
17+
let spanName = `SNS ${request.commandName}`;
18+
const spanAttributes: Record<string, unknown> = {
19+
[MESSAGING_SYSTEM]: 'aws.sns',
20+
};
21+
22+
if (request.commandName === 'Publish') {
23+
spanKind = SPAN_KIND.PRODUCER;
24+
25+
spanAttributes[ATTR_MESSAGING_DESTINATION_KIND] = MESSAGING_DESTINATION_KIND_VALUE_TOPIC;
26+
const { TopicArn, TargetArn, PhoneNumber } = request.commandInput;
27+
const destinationName = extractDestinationName(TopicArn, TargetArn, PhoneNumber);
28+
spanAttributes[ATTR_MESSAGING_DESTINATION] = destinationName;
29+
spanAttributes[MESSAGING_DESTINATION_NAME] = TopicArn || TargetArn || PhoneNumber || 'unknown';
30+
31+
spanName = `${PhoneNumber ? 'phone_number' : destinationName} send`;
32+
}
33+
34+
const topicArn = request.commandInput?.TopicArn;
35+
if (topicArn) {
36+
spanAttributes[ATTR_AWS_SNS_TOPIC_ARN] = topicArn;
37+
}
38+
39+
return {
40+
spanAttributes,
41+
spanKind,
42+
spanName,
43+
};
44+
}
45+
46+
public requestPostSpanHook(request: NormalizedRequest, span: Span): void {
47+
if (request.commandName === 'Publish') {
48+
const origMessageAttributes = request.commandInput.MessageAttributes ?? {};
49+
request.commandInput.MessageAttributes = injectPropagationContext(origMessageAttributes, span);
50+
}
51+
}
52+
53+
public responseHook(response: NormalizedResponse, span: Span): void {
54+
const topicArn = response.data?.TopicArn;
55+
if (topicArn) {
56+
span.setAttribute(ATTR_AWS_SNS_TOPIC_ARN, topicArn);
57+
}
58+
}
59+
}
60+
61+
function extractDestinationName(topicArn: string, targetArn: string, phoneNumber: string): string {
62+
if (topicArn || targetArn) {
63+
const arn = topicArn ?? targetArn;
64+
try {
65+
return arn.substring(arn.lastIndexOf(':') + 1);
66+
} catch {
67+
return arn;
68+
}
69+
} else if (phoneNumber) {
70+
return phoneNumber;
71+
} else {
72+
return 'unknown';
73+
}
74+
}

0 commit comments

Comments
 (0)