|
| 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