Skip to content

Commit 298d80e

Browse files
committed
feat(server-utils): Add orchestrion aws-sdk channel integration core
1 parent 2cfeb04 commit 298d80e

14 files changed

Lines changed: 452 additions & 2 deletions

File tree

.oxlintrc.base.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@
147147
"no-param-reassign": "off"
148148
}
149149
},
150+
{
151+
"files": ["**/integrations/tracing-channel/aws-sdk/**/*.ts"],
152+
"rules": {
153+
"typescript/no-explicit-any": "off"
154+
}
155+
},
150156
{
151157
"files": ["**/integrations/tracing/redis/vendored/**/*.ts"],
152158
"rules": {

dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ describe('awsIntegration (streamed)', () => {
221221
await createTestRunner().ignore('event').expect({ span: assertAwsServiceSpans }).start().completed();
222222
});
223223
},
224-
{ additionalDependencies },
224+
// The orchestrion aws-sdk channel integration has no service extensions yet (empty registry),
225+
// so it can't emit the service-specific attributes asserted here. Stay on the OTel path until
226+
// the service extensions land in a follow-up.
227+
{ additionalDependencies, injectOrchestrion: false },
225228
);
226229
});
227230
});

dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,10 @@ describe('awsIntegration', () => {
216216
await createTestRunner().ignore('event').expect({ transaction: assertAwsServiceSpans }).start().completed();
217217
});
218218
},
219-
{ additionalDependencies },
219+
// The orchestrion aws-sdk channel integration has no service extensions yet (empty registry),
220+
// so it can't emit the service-specific attributes asserted here. Stay on the OTel path until
221+
// the service extensions land in a follow-up.
222+
{ additionalDependencies, injectOrchestrion: false },
220223
);
221224
});
222225
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* AWS-specific span constants used by the aws-sdk channel integration that are NOT covered by
3+
* `@sentry/conventions/attributes` (attribute names that exist there are imported from there
4+
* directly). Per-service files append their own such constants below.
5+
*/
6+
7+
/** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */
8+
export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk';
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Span } from '@sentry/core';
2+
import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types';
3+
4+
export type { RequestMetadata };
5+
6+
export interface ServiceExtension {
7+
// called before the request is sent, and before the span is started
8+
requestPreSpanHook: (request: NormalizedRequest) => RequestMetadata;
9+
10+
// called before the request is sent, and after the span is started. `span` is the started span,
11+
// used to derive trace-propagation headers injected into outgoing messages.
12+
requestPostSpanHook?: (request: NormalizedRequest, span: Span) => void;
13+
14+
// Called after the response is received. Unlike the OTel middleware patch, a tracing-channel
15+
// subscriber cannot replace the value the caller's promise resolves with: the injected settle
16+
// handler returns the captured result, not `ctx.result`. It does however publish `asyncEnd`
17+
// synchronously BEFORE the caller's continuations run, and `response.data` is the same object the
18+
// caller receives, so extensions that need to alter the response (e.g. wrap a stream) must mutate
19+
// `response.data` in place; the mutation is guaranteed to be visible to the caller. Same idiom as
20+
// the vercel-ai subscribers' `result.stream` tap.
21+
responseHook?: (response: NormalizedResponse, span: Span) => void;
22+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Span } from '@sentry/core';
2+
import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types';
3+
import type { ServiceExtension } from './ServiceExtension';
4+
5+
export class ServicesExtensions implements ServiceExtension {
6+
// Per-service extensions, keyed by the client's `serviceId` (e.g. `'S3'`). Services without a
7+
// registered extension still get the base rpc span from the subscriber.
8+
private _services: Map<string, ServiceExtension> = new Map();
9+
10+
public requestPreSpanHook(request: NormalizedRequest): RequestMetadata {
11+
const serviceExtension = this._services.get(request.serviceName);
12+
if (!serviceExtension) {
13+
return {};
14+
}
15+
return serviceExtension.requestPreSpanHook(request);
16+
}
17+
18+
public requestPostSpanHook(request: NormalizedRequest, span: Span): void {
19+
const serviceExtension = this._services.get(request.serviceName);
20+
serviceExtension?.requestPostSpanHook?.(request, span);
21+
}
22+
23+
public responseHook(response: NormalizedResponse, span: Span): void {
24+
const serviceExtension = this._services.get(response.request.serviceName);
25+
serviceExtension?.responseHook?.(response, span);
26+
}
27+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ServicesExtensions } from './ServicesExtensions';

0 commit comments

Comments
 (0)