Skip to content

Commit 74c45a2

Browse files
feat(handlers): add shared ua module for SDK v3 User-Agent solution tracking
abcaUserAgent() bakes app/uksb-wt64nei4u6/{STACKNAME} (single-element customUserAgent pair — the name path preserves '/') and ['md/uksb-wt64nei4u6', component] (the '#' comes from the SDK's own name#value join; a '#' inside a name would be escaped to '-'). Component label from ABCA_COMPONENT env since shared modules bundle into multiple Lambdas. withAbcaTrace() adds a middleware after getUserAgentMiddleware that splices #{TRACE} onto the md/ segment in both UA headers per-request; no-ops on bare-object constructor mocks. Wire-capture tests run the full middleware stack against a stub requestHandler and assert the emitted headers for trace-present, trace-absent, and hostile-input cases. Task 3 of PR #338 plan. Part of #319 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7a05937 commit 74c45a2

2 files changed

Lines changed: 427 additions & 0 deletions

File tree

cdk/src/handlers/shared/ua.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
/**
21+
* Outbound AWS SDK User-Agent solution tracking (#319).
22+
*
23+
* Every AWS API call made by the Lambda handlers carries two ABCA
24+
* solution-tracking segments in the `User-Agent` header:
25+
*
26+
* app/uksb-wt64nei4u6/{STACKNAME} (only when ABCA_STACK_NAME set)
27+
* md/uksb-wt64nei4u6#{COMPONENT}[#{TRACE}]
28+
*
29+
* Both ride the verbatim `customUserAgent` path — NOT the sanitizing
30+
* `userAgentAppId` config field, whose allowed charset excludes `/` and would
31+
* mangle the `uksb-wt64nei4u6/` separator into `-`. Because the raw path
32+
* applies only pass-through escaping to our token-safe characters, this
33+
* module sanitizes `{STACKNAME}` and `{TRACE}` itself.
34+
*
35+
* The static part is baked once at client construction via
36+
* {@link abcaUserAgent}. The optional `#{TRACE}` suffix (the handler's ulid
37+
* request id, or a task id) is appended **per request** by the middleware
38+
* {@link withAbcaTrace} adds — never via client config — so module-level
39+
* cached clients keep their connection pools across trace changes.
40+
*
41+
* Trace state is a module-level variable: a Lambda execution environment
42+
* processes one invocation at a time, so ambient module state is safe (and
43+
* survives across the SDK's async internals where per-call threading can't).
44+
*
45+
* Counterparts: `agent/src/ua.py` (Python agent runtime) and `cli/src/ua.ts`
46+
* (bgagent CLI). Solution id, wire format, and sanitization rules must stay
47+
* identical across all three.
48+
*/
49+
50+
/**
51+
* AWS solution-tracking id for ABCA. Deploy-time counterpart (#292) lives in
52+
* the CloudFormation stack description in `cdk/src/main.ts`. Per-surface
53+
* literal by design — see PR #338.
54+
*/
55+
export const SOLUTION_ID = 'uksb-wt64nei4u6';
56+
57+
/**
58+
* Env var carrying the stable per-component label (`api`, `webhook`,
59+
* `orchestr`) — set per-Lambda by the CDK constructs. Shared handler modules
60+
* are bundled into multiple Lambdas, so identity must come from the
61+
* environment, not from code.
62+
*/
63+
export const COMPONENT_ENV = 'ABCA_COMPONENT';
64+
65+
/** Env var carrying the deployed CloudFormation stack name (set by CDK). */
66+
export const STACK_NAME_ENV = 'ABCA_STACK_NAME';
67+
68+
/** Default component label when ABCA_COMPONENT is absent (REST API surface). */
69+
const DEFAULT_COMPONENT = 'api';
70+
71+
/**
72+
* App-id budget: the documented 50-char cap on the value, minus
73+
* `uksb-wt64nei4u6/` (16 chars), leaves 34 for the stack name.
74+
*/
75+
const STACK_NAME_MAX = 34;
76+
77+
/**
78+
* RFC 7230 token charset (the UA product-token alphabet). `/` and `#` are
79+
* deliberately excluded — they are the structural separators of the scheme.
80+
* Mirrors `_ALLOWED` in `agent/src/ua.py`.
81+
*/
82+
const UA_TOKEN_SAFE = /[^A-Za-z0-9!$%&'*+\-.^_`|~]/g;
83+
84+
let currentTrace: string | undefined;
85+
86+
/** Replace every non-UA-token char (incl. non-ASCII) with `-`. */
87+
export function sanitizeUaValue(raw: string): string {
88+
return raw.replace(UA_TOKEN_SAFE, '-');
89+
}
90+
91+
/** The component label for this Lambda (from env, sanitized). */
92+
function componentLabel(): string {
93+
return sanitizeUaValue(process.env[COMPONENT_ENV]?.trim() || DEFAULT_COMPONENT);
94+
}
95+
96+
/** The static `md/` segment as it renders on the wire. */
97+
function mdSegment(): string {
98+
return `md/${SOLUTION_ID}#${componentLabel()}`;
99+
}
100+
101+
/**
102+
* Client config fragment carrying the static ABCA UA segments.
103+
*
104+
* Spread into any SDK v3 client constructor:
105+
* `new DynamoDBClient({ ...abcaUserAgent() })`. Each entry is a
106+
* `[name, value?]` user-agent pair. The SDK's escaper treats the two
107+
* positions differently: the *name* is split on `/`, each part escaped
108+
* (where `#` is NOT allowed and becomes `-`), and rejoined with `/`; the
109+
* *value* allows `#` and is joined to the name with `#`. So:
110+
*
111+
* - the `app/` segment is a single-element pair — its only separators are
112+
* slashes, which survive the name path (this is what keeps the literal
113+
* `/` that the sanitizing app-id config field would destroy);
114+
* - the `md/` segment is a two-element pair `['md/{id}', component]`,
115+
* rendering `md/{id}#component` — the `#` comes from the SDK's own
116+
* name#value join, not from our string (a `#` inside the name would be
117+
* escaped to `-`).
118+
*/
119+
export function abcaUserAgent(): { customUserAgent: ([string] | [string, string])[] } {
120+
const pairs: ([string] | [string, string])[] = [];
121+
const stackName = process.env[STACK_NAME_ENV]?.trim();
122+
if (stackName) {
123+
// Sanitize FIRST, then clip, so a replaced char can't be re-split.
124+
const clipped = sanitizeUaValue(stackName).slice(0, STACK_NAME_MAX);
125+
pairs.push([`app/${SOLUTION_ID}/${clipped}`]);
126+
}
127+
pairs.push([`md/${SOLUTION_ID}`, componentLabel()]);
128+
return { customUserAgent: pairs };
129+
}
130+
131+
/**
132+
* Set (or clear, by omitting the argument) the ambient trace handle.
133+
* Handlers call this with their per-invocation request id right after
134+
* minting it; the orchestrator uses the task id.
135+
*/
136+
export function setAbcaTrace(handle?: string): void {
137+
currentTrace = handle || undefined;
138+
}
139+
140+
/** Current trace handle, sanitized to UA-token-safe ASCII, or undefined. */
141+
export function getAbcaTrace(): string | undefined {
142+
return currentTrace ? sanitizeUaValue(currentTrace) : undefined;
143+
}
144+
145+
/**
146+
* Minimal structural view of an SDK v3 client middleware stack — enough to
147+
* add the trace middleware without importing @smithy/types (which is not a
148+
* declared dependency of the handlers).
149+
*/
150+
interface MiddlewareStackLike {
151+
addRelativeTo(middleware: unknown, options: Record<string, unknown>): void;
152+
}
153+
154+
/**
155+
* Append `#{TRACE}` to the outgoing User-Agent headers on every request.
156+
*
157+
* Adds a middleware right after the SDK's own `getUserAgentMiddleware`
158+
* (step `build`) that splices the current trace onto the static `md/`
159+
* segment in both `user-agent` and `x-amz-user-agent`. Only the header
160+
* strings change — the client, its config, and its connection pool are
161+
* untouched, so cached/module-level clients are reused freely across traces.
162+
*
163+
* No-ops when the client has no middleware stack: ~40 existing test suites
164+
* mock client constructors as `jest.fn(() => ({}))`, and module-level
165+
* instrumentation must not crash under those mocks. Real SDK clients always
166+
* have a stack, so the guard is test-environment-only.
167+
*/
168+
export function withAbcaTrace<T>(client: T): T {
169+
const stack = (client as { middlewareStack?: MiddlewareStackLike }).middlewareStack;
170+
if (!stack || typeof stack.addRelativeTo !== 'function') {
171+
return client;
172+
}
173+
const md = mdSegment();
174+
stack.addRelativeTo(
175+
(next: (args: unknown) => Promise<unknown>) => async (args: unknown) => {
176+
const trace = getAbcaTrace();
177+
const request = (args as { request?: { headers?: Record<string, string> } }).request;
178+
if (trace && request?.headers) {
179+
for (const header of ['user-agent', 'x-amz-user-agent']) {
180+
const value = request.headers[header];
181+
if (value && value.includes(md)) {
182+
request.headers[header] = value.replace(md, `${md}#${trace}`);
183+
}
184+
}
185+
}
186+
return next(args);
187+
},
188+
{
189+
name: 'abcaUaTraceMiddleware',
190+
relation: 'after',
191+
toMiddleware: 'getUserAgentMiddleware',
192+
override: true,
193+
},
194+
);
195+
return client;
196+
}

0 commit comments

Comments
 (0)