Skip to content

feat(observability): solution attribution via native AWS_SDK_UA_APP_ID (#319, alt to #338) - #345

Open
scottschreckengaust wants to merge 8 commits into
mainfrom
feat/319-sdk-user-agent-appid
Open

feat(observability): solution attribution via native AWS_SDK_UA_APP_ID (#319, alt to #338)#345
scottschreckengaust wants to merge 8 commits into
mainfrom
feat/319-sdk-user-agent-appid

Conversation

@scottschreckengaust

@scottschreckengaust scottschreckengaust commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Attribute every outbound AWS SDK call from ABCA (agent runtime, Lambda handlers, bgagent CLI) to the solution via the SDK-native AWS_SDK_UA_APP_ID env var plus a static md/ User-Agent segment — no per-request middleware.

Closes #319

Review remediation (theagenticguy)

CHANGES_REQUESTED addressed in commit a503ee76. Each unresolved inline thread + summary nit maps to a concrete fix (also replied in-thread):

Thread (path:line) Fix
BLOCKING solution-ua-aspect.ts:50 — override sanitized with UA_TOKEN_UNSAFE mangled #- Added sanitizeAppId(): split on #, sanitize each segment, rejoin. buildAppId('X','uksb-wt64nei4u6#my-stack') now returns it verbatim; both branches clip to APP_ID_MAX_LEN.
BLOCKING solution-ua-aspect.test.ts:44 — no # case, bug never tripped CI Added the #-preservation assertion + adversarial cases (multi-#, #+slash mix, over-length clip preserving #). Failed before the fix, passes now.
BLOCKING ecs-strategy.ts:30 — 8 new S3Client({}) sites unattributed Attributed all 8 with ...abcaUserAgent() (confirm-uploads, get-trace-url, cleanup-pending-uploads, shared/create-task-core, shared/orchestrator, ecs-strategy getS3Client(), github-webhook-processor, jira-webhook-processor). Also attributed the co-located naked DynamoDB + Bedrock clients in jira-webhook-processor. Zero naked AWS SDK clients remain in cdk/src.
SHOULD-FIX aws_session.py:271Config.merge drops caller's user_agent_extra _merge_ua_config now concatenates f"{caller_extra} {static_user_agent_extra()}" when the caller set an extra (both survive; non-colliding keys still merge). Docstring corrected.
SHOULD-FIX test_aws_session.py:347 — fixture used the one non-colliding key Rewrote to pass Config(read_timeout=7, user_agent_extra='caller/1.0') and assert both caller/1.0 and md/…#agent survive. Added a second test for the no-collision path.
SMALLER ua.ts:83 api fallback — jira mislabeled Added ComponentUaAspect('webhook') to jira-integration.ts (matches slack/linear/github-screenshot); its 3 Lambdas now report md/…#webhook.

Summary nits:

  • WebhookCreateTaskFn relabeled webhook (it inherited createTask's api label but is a webhook surface).
  • apiKeyEnv + api-key authorizer now set ABCA_COMPONENT: 'api' explicitly (no longer relying on the fallback).
  • CLI admitted-gaps — attributed every remaining naked AWS SDK client instead of listing them: runtime-status.ts, platform-doctor.ts (x2), webhook-test.ts, github-token.ts (x2), dynamo-clients.ts (x2), and the jira command's SecretsManager / DynamoDB / CloudFormation clients. No "etc.".
  • Coverage — nested-scope synth test in agent.test.ts asserts AWS_SDK_UA_APP_ID on deeply-nested integration Lambdas (Jira/Slack/Linear), with the two CDK-internal custom-resource provider Lambdas explicitly excluded (see honest-gaps note below); task-api.test.ts now covers the api-key (api) vs webhook (webhook) label split (the prior loop passed vacuously); jira-integration.test.ts guards the webhook label.
  • NitSolutionUaAspect applied at AspectPriority.MUTATING in main.ts so it runs before cdk-nag (500), matching the agent stack.

Reproduced root cause + evidence

#319 asks that outbound AWS API calls carry ABCA solution-attribution segments so AWS can measure usage. The original constraint (and the reason the earlier /-separated design in #338 was rejected): the SDK's AWS_SDK_UA_APP_ID field sanitizes disallowed characters to -, which would mangle a app/uksb-wt64nei4u6/{stack} separator into app-uksb-wt64nei4u6-{stack}.

Verified against the pinned SDKs in this repo:

  • botocore 1.43.42 (Python) — with AWS_SDK_UA_APP_ID=uksb-wt64nei4u6#my-stack-name and user_agent_extra='md/uksb-wt64nei4u6#agent', the assembled UA is:
    … app/uksb-wt64nei4u6#my-stack-name md/uksb-wt64nei4u6#agent — both segments verbatim, # survives.
  • @aws-sdk/core 3.974.29 (JS v3) — UA_VALUE_ESCAPE_REGEX = /[^!$%&'*+\-.^_|~\w#]/g**allowlists#**; isValidUserAgentAppIdonly enforceslength <= 50(no charset filter). App-id value andcustomUserAgentvalues render#verbatim; a/-form value escapes to app-uksb-wt64nei4u6-my-stack(proving why/` was unusable).

The fix, and why native AWS_SDK_UA_APP_ID is best-practice

  • app/ segment (universal, SDK-native) — a SolutionUaAspect (cdk/src/constructs/solution-ua-aspect.ts) sets AWS_SDK_UA_APP_ID on every Lambda at the stack level (at AspectPriority.MUTATING); the AgentCore runtime + ECS container get it too. buildAppId(stackName) emits uksb-wt64nei4u6#{stackName}; buildAppId's override branch now sanitizes per #-delimited segment so the separator is never mangled, clipped to the documented 50-char cap. No client code touches app/; new clients inherit it structurally.
  • md/ segment (per-surface label) — three parity helpers build md/uksb-wt64nei4u6#{component} and spread it into client constructors: agent/src/ua.py (user_agent_extra, via aws_session), cdk/src/handlers/shared/ua.ts (...abcaUserAgent()), cli/src/ua.ts (...abcaUserAgent()). The three modules are identical in solution id, wire format, and RFC-7230 token sanitization (all three md/-label sanitizers still deliberately exclude #; only the CDK-only app-id builder preserves # as the separator).
  • Using the native field (vs. a hand-rolled before-send handler / JS middleware) means zero per-request work, no connection-pool re-pinning, and future clients are covered by the aspect + the documented helper pattern.

Customer opt-out preserved: deploy with -c sdkUaAppId='' (aspect becomes a no-op, no app/ segment) or export AWS_SDK_UA_APP_ID='' for the CLI.

Deviations from #319 spec

Testing

Run from the rebased worktree (feat/319-sdk-user-agent-appid, on latest origin/main):

  • MISE_EXPERIMENTAL=1 mise //cdk:eslint → clean; mise //cli:eslint → clean (both --fix, no mutations)
  • MISE_EXPERIMENTAL=1 mise //cdk:compile + //cli:compile → clean
  • MISE_EXPERIMENTAL=1 mise //cdk:test138 suites, 2562 tests pass; 1 snapshot pass (ua.ts 100%; includes the new #-override, nested-scope synth, and api-key/webhook label tests)
  • MISE_EXPERIMENTAL=1 mise //cli:test53 suites, 653 tests pass
  • MISE_EXPERIMENTAL=1 mise //agent:quality1315 tests pass, coverage 82.00% (ruff + ty clean; includes the merge-concat test)
  • mise run security:sast → clean; mise run security:secrets scoped to this diff (staged) → no leaks found
  • SDK-native #-survival verified empirically against botocore 1.43.42 and @aws-sdk/core 3.974.29 (see evidence above)

Known baseline (pre-existing on main, not introduced by this PR, out of scope):

  • mise //cdk:synth fails on ec2:DescribeAvailabilityZones — a documented hermeticity/creds gap on fresh checkouts (needs live AWS creds for the VPC AZ lookup), not a code defect. //cdk:compile and //cdk:test are green.
  • Full-history gitleaks flags 3 pre-existing aws-account-id false positives (commit 1cfa5b9f, not in this diff); the per-diff staged scan is clean.

Honest coverage gaps (deliberately not attributed)

  • CDK custom-resource provider LambdasCustomS3AutoDeleteObjects… and CustomVpcRestrictDefaultSG… are CDK-framework-owned CfnResource-backed handlers (not lambda.Function constructs), so the aspect's instanceof lambda.Function guard does not visit them. They fire only at deploy time and are not runtime solution traffic. The nested-scope synth test excludes them explicitly and asserts every ABCA-authored Lambda IS covered.
  • agent/src/bedrock_creds_helper.py (STS assume-role) — out-of-process helper; STS assume-role in aws_session._build_scoped_session already carries ua.client_config(). Left as an explicit follow-up, not silently deferred.

Rebase reconciliation notes

Rebased onto latest origin/main; the CLI Cognito/CloudFormation clients live in cli/src/cognito-admin.ts + cli/src/stack-outputs.ts and carry ...abcaUserAgent(). AGENTS.md's #319 note is a single condensed bullet in ## Common mistakes.

Self-review note

An earlier self-review pass fixed a regression this PR introduced: resolve_linear_api_token in agent/src/config.py moved an SDK import outside the graceful-skip guard; re-added an explicit import boto3 availability probe inside the guard.

Dependencies / related

🤖 Generated with Claude Code

@scottschreckengaust

scottschreckengaust commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

✅ Tested & verified — outbound User-Agent on the wire (both runtime tiers)

Validated that this PR's solution-attribution segments land on the actual outbound User-Agent of live AWS SDK calls, in both runtime tiers, using the PR's own helpers (no reimplementation). Verified 2026-06-19 against botocore 1.43.29 + aws-sdk-js 3.1068.0 (Python 3.13 / Node 22), profile with real AWS creds.

What was checked

Two standalone wire-capture scripts import the real helpers and print the assembled User-Agent of live calls. Each attaches a capture hook that fires after the SDK assembles the UA but before the response (so the header prints even if the call is denied), and exercises a generic client plus DynamoDB, S3, and Secrets Manager:

Tier Real helper imported md/ component
Lambda handlers (Node 24) abcaUserAgent()cdk/src/handlers/shared/ua.ts ABCA_COMPONENT env (api/orchestr/webhook)
Agent runtime (Python 3.13 / boto3) client_config()agent/src/ua.py hard-wired agent

Result — every call, both tiers (contains md/uksb-wt64nei4u6#... ? YES)

# Node (ABCA_COMPONENT=orchestr), Lambda GetAccountSettings / DynamoDB / S3 / SecretsManager:
User-Agent: aws-sdk-js/3.1068.0 ... app/uksb-wt64nei4u6#integ-1910531 md/uksb-wt64nei4u6#orchestr
            (md/ segment also present on the x-amz-user-agent header)

# Python (agent), STS GetCallerIdentity / DynamoDB / S3 / SecretsManager:
User-Agent: Boto3/1.43.9 ... app/uksb-wt64nei4u6#integ-1910531 Botocore/1.43.29 md/uksb-wt64nei4u6#agent
  • The app/uksb-wt64nei4u6#… segment was emitted natively by each SDK purely from the AWS_SDK_UA_APP_ID env var (zero client code) — confirming the PR's core "use # to keep the app-id native" claim.
  • Setting AWS_SDK_UA_APP_ID='' drops the app/ segment while md/ remains — confirms the documented customer opt-out.
  • The generic client (Lambda GetAccountSettings / STS GetCallerIdentity) carries the pair too — proving it rides on any client from spreading the helper, not just hand-picked ones.

Note on CloudTrail (why wire-capture)

CloudTrail's userAgent field is the same wire header, server-side — but DynamoDB data events are rejected account-wide in the test account (UnsupportedOperationException on both put-event-selectors and CloudTrail Lake create-event-data-store; the account is not in an Organization, so it's an account-level CloudTrail restriction, not an SCP). Management events (Bedrock InvokeModel, Cognito, STS, Secrets Manager) and S3 data events are observable via a trail; DynamoDB is not. Wire-capture proves the DynamoDB UA directly and is runtime-agnostic.


Test script — Node / Lambda tier (cdk/ua-wire-check.ts)
/**
 * UA wire-capture check (#319 / PR #345) — standalone, NOT part of the CDK app.
 *
 * Proves the outbound AWS SDK `User-Agent` carries both solution-attribution
 * segments WITHOUT relying on CloudTrail (this account blocks DynamoDB data
 * events, so the wire is the only place to observe them):
 *
 *     app/uksb-wt64nei4u6#{AWS_SDK_UA_APP_ID}   <- SDK reads the env var natively
 *     md/uksb-wt64nei4u6#{ABCA_COMPONENT}       <- from the REAL abcaUserAgent() helper
 *
 * It imports the PR's actual helper (src/handlers/shared/ua.ts) — no mirror —
 * spreads it into real SDK v3 clients exactly as the handlers do, attaches a
 * finalizeRequest middleware that captures the assembled User-Agent header, and
 * makes one cheap read-only call per service. The call may even fail on perms;
 * the UA is captured at request-build time, before the response, so failures
 * still print the header.
 *
 * Run (from the worktree cdk/ dir):
 *   AWS_PROFILE=default AWS_REGION=us-east-1 \
 *   AWS_SDK_UA_APP_ID='uksb-wt64nei4u6#integ-1910531' \
 *   ABCA_COMPONENT=orchestr \
 *   node_modules/.bin/ts-node --transpile-only --compiler-options '{"module":"commonjs"}' ua-wire-check.ts
 *
 * Vary ABCA_COMPONENT (api | orchestr | webhook | agent) to see each md/ label.
 * Set AWS_SDK_UA_APP_ID='' to confirm the app/ segment drops (customer opt-out).
 */

import { LambdaClient, GetAccountSettingsCommand } from '@aws-sdk/client-lambda';
import { LambdaClient, GetAccountSettingsCommand } from '@aws-sdk/client-lambda';
import { DynamoDBClient, ListTablesCommand } from '@aws-sdk/client-dynamodb';
import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3';
import {
  SecretsManagerClient,
  ListSecretsCommand,
} from '@aws-sdk/client-secrets-manager';
import { HttpRequest } from '@smithy/protocol-http';

// The REAL PR helper — this is the thing under test, not a copy.
import { abcaUserAgent, SOLUTION_ID, COMPONENT_ENV } from './src/handlers/shared/ua';

/**
 * Middleware that prints every User-Agent-ish header on the finalized request.
 * finalizeRequest runs AFTER the SDK's user-agent middleware (build step), so
 * the header is fully assembled — app/ from the env var + md/ from the helper.
 */
const captureUa = (label: string) => ({
  applyToStack: (stack: any) => {
    stack.add(
      (next: any) => async (args: any) => {
        const req = args.request;
        if (HttpRequest.isInstance(req)) {
          const ua =
            req.headers['user-agent'] ?? req.headers['User-Agent'] ?? '(none)';
          const xua =
            req.headers['x-amz-user-agent'] ??
            req.headers['X-Amz-User-Agent'] ??
            '(none)';
          // eslint-disable-next-line no-console
          console.log(`\n[${label}]`);
          // eslint-disable-next-line no-console
          console.log(`  User-Agent:        ${ua}`);
          // eslint-disable-next-line no-console
          console.log(`  x-amz-user-agent:  ${xua}`);
          const want = `md/${SOLUTION_ID}`;
          // eslint-disable-next-line no-console
          console.log(
            `  contains ${want}#... ? ${
              String(ua).includes(want) || String(xua).includes(want)
                ? 'YES'
                : 'NO'
            }`,
          );
        }
        return next(args);
      },
      { step: 'finalizeRequest', name: `captureUa-${label}`, priority: 'low' },
    );
  },
});

async function main(): Promise<void> {
  const appId = process.env.AWS_SDK_UA_APP_ID;
  const component = process.env[COMPONENT_ENV];
  // eslint-disable-next-line no-console
  console.log('=== UA wire-capture (#345) ===');
  // eslint-disable-next-line no-console
  console.log(`AWS_SDK_UA_APP_ID = ${appId ?? '(unset → no app/ segment)'}`);
  // eslint-disable-next-line no-console
  console.log(`${COMPONENT_ENV} = ${component ?? '(unset → defaults to api)'}`);
  // eslint-disable-next-line no-console
  console.log(`Expecting md/${SOLUTION_ID}#${component ?? 'api'} on every call.`);

  // Generic / "trivial" client — no specific resource, just a plain SDK v3
  // client built the same way (spread the helper). GetAccountSettings needs no
  // resource and minimal perms, so it's the Node-tier analogue of STS
  // GetCallerIdentity: proves the UA on an arbitrary client, not a special one.
  const lambda = new LambdaClient({ ...abcaUserAgent() });
  lambda.middlewareStack.use(captureUa('Lambda GetAccountSettings (generic)'));

  // Each client built EXACTLY like the handlers: spread the real helper in.
  const ddb = new DynamoDBClient({ ...abcaUserAgent() });
  ddb.middlewareStack.use(captureUa('DynamoDB ListTables'));

  const s3 = new S3Client({ ...abcaUserAgent() });
  s3.middlewareStack.use(captureUa('S3 ListBuckets'));

  const sm = new SecretsManagerClient({ ...abcaUserAgent() });
  sm.middlewareStack.use(captureUa('SecretsManager ListSecrets'));

  // Cheap read-only calls. Wrapped so a perms failure still lets the others run
  // (the UA is already printed by the middleware before any error surfaces).
  const run = async (name: string, fn: () => Promise<unknown>) => {
    try {
      await fn();
    } catch (err) {
      // eslint-disable-next-line no-console
      console.log(`  (${name} call errored after UA capture: ${(err as Error).name})`);
    }
  };

  await run('lambda', () => lambda.send(new GetAccountSettingsCommand({})));
  await run('ddb', () => ddb.send(new ListTablesCommand({ Limit: 1 })));
  await run('s3', () => s3.send(new ListBucketsCommand({})));
  await run('sm', () => sm.send(new ListSecretsCommand({ MaxResults: 1 })));
}

void main();
Test script — Python / agent tier (agent/ua_wire_check.py)
"""UA wire-capture check for the AGENT (Python) tier — #319 / PR #345.

Counterpart to ``cdk/ua-wire-check.ts`` (the Lambda/Node tier). Proves the
agent runtime's outbound boto3 ``User-Agent`` carries both solution-attribution
segments WITHOUT relying on CloudTrail (this account blocks DynamoDB data
events, so the wire is the only place to observe them):

    app/uksb-wt64nei4u6#{AWS_SDK_UA_APP_ID}   <- botocore reads the env var natively
    md/uksb-wt64nei4u6#agent                  <- from the REAL agent helper (ua.py)

It imports the agent's actual helper (``agent/src/ua.py``) — no mirror — builds
clients exactly like ``aws_session.platform_client`` (spreading ``client_config()``),
registers a botocore ``before-send`` handler to capture the fully-assembled
request headers, and makes one cheap read-only call per service. ``before-send``
fires with the signed request in hand; the UA is captured before the response,
so a perms failure still prints the header.

Run (from the worktree, with the agent venv that has boto3):

    cd agent
    AWS_PROFILE=default AWS_REGION=us-east-1 \
    AWS_SDK_UA_APP_ID='uksb-wt64nei4u6#integ-1910531' \
    .venv/bin/python ua_wire_check.py

Set AWS_SDK_UA_APP_ID='' to confirm the app/ segment drops (customer opt-out).
The md/ component is hard-wired to ``agent`` in ua.py (this surface IS the
agent), unlike the Node tier where ABCA_COMPONENT selects api/orchestr/webhook.
"""

from __future__ import annotations

import os
import sys

import boto3
from botocore.exceptions import BotoCoreError, ClientError

# Make ``agent/src`` importable when run from ``agent/``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))

# The REAL agent helper — the thing under test, not a copy.
from ua import COMPONENT, SOLUTION_ID, client_config  # noqa: E402


def _make_capture(label: str):
    """Return a botocore ``before-send`` handler that prints the wire UA."""

    def _capture(request, **_kwargs):  # noqa: ANN001
        ua = request.headers.get("User-Agent") or request.headers.get("user-agent") or "(none)"
        if isinstance(ua, (bytes, bytearray)):
            ua = ua.decode("utf-8", "replace")
        want = f"md/{SOLUTION_ID}"
        print(f"\n[{label}]")
        print(f"  User-Agent: {ua}")
        print(f"  contains {want}#... ? {'YES' if want in ua else 'NO'}")
        return None  # don't short-circuit; let the real request proceed

    return _capture


def _client(service: str, label: str):
    """boto3 client built like aws_session.platform_client + a UA capture hook."""
    client = boto3.client(service, config=client_config())
    # 'before-send.<service>' fires once the request is fully built & signed.
    client.meta.events.register("before-send", _make_capture(label))
    return client


def main() -> None:
    app_id = os.environ.get("AWS_SDK_UA_APP_ID")
    print("=== UA wire-capture: AGENT tier (#345) ===")
    print(f"AWS_SDK_UA_APP_ID = {app_id if app_id is not None else '(unset -> no app/ segment)'}")
    print(f"Expecting md/{SOLUTION_ID}#{COMPONENT} on every call.")

    calls = [
        ("STS GetCallerIdentity", "sts", lambda c: c.get_caller_identity()),
        ("DynamoDB ListTables", "dynamodb", lambda c: c.list_tables(Limit=1)),
        ("S3 ListBuckets", "s3", lambda c: c.list_buckets()),
        ("SecretsManager ListSecrets", "secretsmanager", lambda c: c.list_secrets(MaxResults=1)),
    ]

    for label, service, op in calls:
        client = _client(service, label)
        try:
            op(client)
        except (ClientError, BotoCoreError) as err:
            # UA already printed by the before-send hook; note the call outcome.
            print(f"  ({service} call errored after UA capture: {type(err).__name__})")


if __name__ == "__main__":
    main()

How to run

# Node / Lambda tier (from cdk/) — let ts-node use the project tsconfig
# (do NOT pass --compiler-options module=commonjs; conflicts with moduleResolution=NodeNext)
AWS_REGION=us-east-1 AWS_SDK_UA_APP_ID='uksb-wt64nei4u6#<stack>' ABCA_COMPONENT=orchestr \
  node_modules/.bin/ts-node --transpile-only ua-wire-check.ts

# Python / agent tier (from agent/, agent venv with boto3)
AWS_REGION=us-east-1 AWS_SDK_UA_APP_ID='uksb-wt64nei4u6#<stack>' \
  .venv/bin/python ua_wire_check.py

@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

❓ Follow-up: is there an intentional asymmetry in the opt-out? (app/ can be disabled, md/ cannot)

While verifying the wire output I tested AWS_SDK_UA_APP_ID='' and observed that only the app/ segment drops — the md/ segment always remains on both tiers:

# Node (ABCA_COMPONENT=orchestr), AWS_SDK_UA_APP_ID='':
User-Agent: aws-sdk-js/3.1068.0 ... m/E,w,v md/uksb-wt64nei4u6#orchestr
            (no app/ segment; md/ unchanged)

# Python (agent), AWS_SDK_UA_APP_ID='':
User-Agent: Boto3/1.43.9 ... cfg/retry-mode#legacy Botocore/1.43.29 md/uksb-wt64nei4u6#agent
            (no app/ segment; md/ unchanged)

This is consistent with the design — the two segments come from independent sources:

  • app/ ← the SDK reading AWS_SDK_UA_APP_ID natively. buildAppId() returns undefined for an empty override, SolutionUaAspect then no-ops, so the env var is never set → no app/. (CLI: applyDefaultAppId() never overrides an existing value, incl. ''.)
  • md/ ← baked unconditionally into each client at construction by abcaUserAgent() / static_user_agent_extra(), with no env var or context flag gating it.

Question

Is "no opt-out for md/" intentional? A couple of readings:

  • If yes (likely): md/uksb-wt64nei4u6#{component} is the bare anonymous solution marker; a customer opting out of the per-deployment app/uksb-wt64nei4u6#{stackName} correlation tag still emits the anonymous marker. Defensible — opting out of identifying your deployment ≠ opting out of the solution being counted. If that's the intent, it'd be worth a one-line note in the docs/PR body so "customer opt-out" isn't read as "suppress all attribution."
  • If a full suppression switch is in scope: it doesn't exist today. Mechanically it looks small — gate the three helpers (cdk/src/handlers/shared/ua.ts, cli/src/ua.ts, agent/src/ua.py) to return an empty config when a disable flag is set. Call sites already spread the helper (new Client({ ...abcaUserAgent() })), so an empty return is a no-op and no call site changes. The care is keeping all three helpers behaviorally identical (the parity the docstrings require) and adding an opt-out assertion to the existing wire-capture tests.

No code change requested here — just confirming whether md/-suppression is in scope or intentionally omitted, so the opt-out wording matches the behavior.

@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

💡 Follow-up (design): make AWS_SDK_UA_APP_ID at synth the single source of truth for all three attribution surfaces

Extending the opt-out thread above — there's a third attribution surface beyond the two wire segments: the CloudFormation stack description (#292), which today hardcodes the solution id in cdk/src/main.ts:

description: 'ABCA Development Stack (uksb-wt64nei4u6)',   // synth-time string literal

So the solution id uksb-wt64nei4u6 is currently a literal repeated across four placescdk/src/handlers/shared/ua.ts, cli/src/ua.ts, agent/src/ua.py (the md/ helpers), and main.ts (the description) — and the app/ value is separately derived in buildAppId().

The three surfaces and where each gets the id today

Surface Where the id comes from Honors an override?
app/{id}#{stackName} (wire) buildAppId()AWS_SDK_UA_APP_ID env set by SolutionUaAspect -c sdkUaAppId=... (and '' opts out)
md/{id}#{component} (wire) hardcoded SOLUTION_ID in each helper ❌ literal
({id}) (stack description) hardcoded literal in main.ts ❌ literal

Proposed ideal behavior

A customer provides the solution id once at synth time and that value is used consistently across all three surfaces. Either input path is acceptable and they can share one resolver — the synth-time AWS_SDK_UA_APP_ID env var or the existing -c sdkUaAppId=... context flag (with the same precedence buildAppId already uses):

  • unset → default uksb-wt64nei4u6 everywhere (today's behavior, unchanged);
  • set to myco-xyzapp/myco-xyz#{stackName}, md/myco-xyz#{component}, and description (myco-xyz);
  • -c sdkUaAppId='' (or AWS_SDK_UA_APP_ID='') → no app/, no md/, and a plain description (full opt-out).

This collapses four hardcoded literals into one synth-time input and makes the opt-out coherent (one switch governs all three, instead of -c sdkUaAppId='' silencing only app/ while md/ and the description still carry uksb-wt64nei4u6).

Feasibility / notes (no change requested — just scoping the conversation)

  • Synth-time only. main.ts is plain Node, so process.env.AWS_SDK_UA_APP_ID and tryGetContext are available there; the description is assembled before the template is written. CloudFormation's Description field itself is a static string (no Ref/Fn::Sub), so this must be resolved at synth — which is exactly where the value already lives.
  • Today AWS_SDK_UA_APP_ID carries the full app-id (uksb#stack), not just the solution-id prefix. This proposal would treat the synth-time env/context value as the solution id and let buildAppId() continue composing #{stackName}. Worth deciding explicitly: is the customer input the solution id (prefix) or the entire app-id? They differ for the md/ and description surfaces.
  • Ordering: the description is built on main.ts:42, but the override is read on :50. Gating the description means hoisting that read above stack construction — small, but a real reorder.
  • Parity: the three md/ helpers must consume the override identically (the cross-surface parity the docstrings already require), and the wire-capture tests would gain an override + opt-out assertion.

Does treating a single synth-time AWS_SDK_UA_APP_ID (solution-id) as the source of truth for app/ + md/ + description fit the intended design for #319/#292?

@scottschreckengaust
scottschreckengaust force-pushed the feat/319-sdk-user-agent-appid branch from e4c53e2 to 2db8b4d Compare July 29, 2026 01:12
scottschreckengaust added a commit that referenced this pull request Jul 29, 2026
…uard (#319)

The UA rework swapped resolve_linear_api_token's boto3.client(...) for
platform_client(...), which imports boto3 lazily at call time. That moved the
SDK import outside the try/except ImportError guard, so a missing SDK would
raise an uncaught ImportError (propagating through the pipeline) instead of the
pre-feature graceful skip. Re-add an explicit `import boto3` availability probe
inside the guard so the "boto3 unavailable → skip Linear MCP" degradation path
is restored, while still building the client via UA-carrying platform_client.

Found during /review_pr self-review of the #345 rebase.

Closes #319

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@scottschreckengaust
scottschreckengaust marked this pull request as ready for review July 29, 2026 01:31
@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

✅ Acceptance summary (for the reviewer)

#319 — outbound AWS SDK User-Agent now carries solution attribution via the native AWS_SDK_UA_APP_ID.

  • SolutionUaAspect sets AWS_SDK_UA_APP_ID on every Lambda; helpers (agent/src/ua.py, cdk/src/handlers/shared/ua.ts, cli/src/ua.ts) build uksb-wt64nei4u6#{stackName}.
  • Sanitization rider CONFIRMED SAFE (empirically): the # separator renders verbatim in both runtimes — botocore 1.43.42 emits app/uksb-wt64nei4u6#{stack} unmangled, and @aws-sdk/core 3.974.29 UA_VALUE_ESCAPE_REGEX allowlists #. No raw-customUserAgent bypass needed. (The /-based form the issue feared would have been mangled — this is precisely why the # separator was chosen.)
  • Deliberate deviation from feat(observability): inject solution into outbound AWS SDK User-Agent #319 spec: the per-request md/...#{TRACE} correlation plane is intentionally dropped; request correlation is delegated to X-Ray / structured logs (tracked in feat(observability): end-to-end task attribution and cross-plane trace correlation #245). Called out so it is not mistaken for an omission.
  • Customer opt-out preserved: -c sdkUaAppId= / AWS_SDK_UA_APP_ID=.
  • Self-review caught + fixed a regression this PR introduced (a boto3 import moved outside an ImportError graceful-skip guard in resolve_linear_api_token → restored an in-guard availability probe, commit 0659a1d).

/review_pr = approve-with-nits, zero blocking. Gates: cdk-test 2555 / cli-test 653 / agent-quality 1314 (81.97%) / docs-sync no-drift / security (deps/sast/retire/gh-actions) all pass.

Chosen over the alternative #338 (raw-UA-path approach): the native AWS_SDK_UA_APP_ID field is the AWS-recommended, lower-maintenance mechanism and needs no per-client middleware. #338 is being closed in favor of this PR.

🔀 Merge guidance (for the reviewer)

Cluster ua-broad LEAD — merge this FIRST. Three PRs in this batch share files with this one (the UA aspect touches ~80 files): #674 (#284), #680 (#238) should rebase after this merges (both are small, low-conflict — an isolated alarm block and a ~2-line command registration). PR #681 (#306) also overlaps and will rebase after. Not strict-up-to-date-gated, so ordering is a courtesy to minimize their rebase, not a hard block. Native auto-merge is disabled repo-wide; awaits your manual squash-merge.

🤖 orchestrator note (agent) — promotion is orchestrator-driven; merge remains a human action.

@theagenticguy theagenticguy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff and independently verified the SDK behavior claims against botocore 1.43.x locally. The core design decision is sound and the evidence in the description holds up: _build_app_id in botocore/useragent.py calls sanitize_user_agent_string_component(raw_str=..., allow_hash=True), so # genuinely survives verbatim in AWS_SDK_UA_APP_ID. Picking the native field over a per-request middleware is the right call, and the aspect-based app/ wiring means future Lambdas are covered structurally. Confirmed the AgentCore runtime and the ECS container both receive the env var, and that ECS containerOverrides merge rather than replace task-definition env, so the app id survives per-task overrides.

Requesting changes on two defects and one coverage gap. Details inline; summary here.

1. buildAppId's override branch mangles #, reintroducing the exact bug this design exists to avoid. -c sdkUaAppId='uksb-wt64nei4u6#my-stack' yields uksb-wt64nei4u6-my-stack. The test at solution-ua-aspect.test.ts:44 locks in the mangling by only checking has/slash, so CI stays green. Blocking.

2. Every S3Client in cdk/src is unattributed, and this is not in the admitted-gaps list. All 8 construction sites lack ...abcaUserAgent(); grep -c abcaUserAgent over them returns 0. ecs-strategy.ts is the clearest case: line 30 was edited by this PR to add the UA, and line 38 in the same file was left alone. S3 carries the attachment and trace-artifact traffic, so the description's "every outbound AWS SDK call" claim fails on a high-volume plane. Blocking, or move it into the documented follow-up explicitly.

3. _merge_ua_config clobbers a caller's user_agent_extra while the test name claims the opposite. Config.merge(other) gives precedence to other, so a caller-supplied extra is dropped. Verified: Config(read_timeout=7, user_agent_extra='caller/1.0').merge(ua) returns md/uksb-wt64nei4u6#agent with caller/1.0 gone. No call site passes config= today, so this is latent rather than live. Should-fix.

Smaller items

  • jira-integration.ts is the only integration that never gets ComponentUaAspect; Slack, Linear, and github-screenshot all do. Its Lambdas fall through to the api default, so Jira webhook traffic reports as the REST API surface. The Jira client sites are also skipped while Linear was converted by the same mechanical edit in this PR, which makes "pre-existing gap" an imprecise framing. Consider saying "Linear converted, Jira deferred" so the follow-up is scoped honestly.
  • webhookCreateTaskFn (task-api.ts) inherits ABCA_COMPONENT: 'api' from createTaskEnv while its sibling webhook Lambdas get webhook, so one surface is labeled inconsistently.
  • The CLI admitted-gaps list says "etc." and omits four real misses: runtime-status.ts:111, platform-doctor.ts:135 and :214, webhook-test.ts:46.
  • No test asserts AWS_SDK_UA_APP_ID on a synthesized template anywhere. agent.test.ts and ecs-agent-cluster.test.ts have zero matches for it, and the aspect tests build one Lambda directly under a stack, so nested-scope traversal and the "every Lambda" claim are both unpinned. The new task-api.test.ts loop runs over the 6-Lambda base template, which excludes the api-key Lambdas that actually lack the label, so it passes vacuously.
  • Nit: the aspect is added at default priority while cdk-nag runs at 500, so nag visits each Lambda before the env mutation. Harmless under current rules, and agent.ts already uses AspectPriority.MUTATING for this hazard.

What is genuinely strong: the wire-format tests. All three surfaces capture through the real middleware stack and assert the literal # across app-id set, unset, and empty-opt-out. Sanitization parity across the three modules is real; byte-comparing the Python frozenset against both JS regexes over ASCII shows an identical accepted set. Import hygiene checks out too: agent/pyproject.toml sets pythonpath = ["src"], there is no cycle, and the re-added import boto3 probe does close the graceful-skip hole because sys.modules caching prevents platform_client's lazy import from raising afterward.

export function buildAppId(stackName: string, override?: string): string | undefined {
if (override !== undefined) {
const trimmed = override.trim();
return trimmed === '' ? undefined : trimmed.replace(UA_TOKEN_UNSAFE, '-').slice(0, APP_ID_MAX_LEN);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this sanitizes the entire override string with UA_TOKEN_UNSAFE, which deliberately excludes #. So the documented canonical form gets mangled:

buildAppId('X', 'uksb-wt64nei4u6#my-stack')  =>  'uksb-wt64nei4u6-my-stack'

That is precisely the app-uksb-wt64nei4u6-{stack} mangling the PR description cites as the reason the / design in #338 was rejected. The default branch on line 52 is correct only because it appends # after sanitizing the stack name.

Suggest sanitizing the override per #-delimited segment, or allowing # in the override charset and clipping after.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Fixed in a503ee7. Added sanitizeAppId() in solution-ua-aspect.ts that splits the override on #, runs UA_TOKEN_UNSAFE per segment, and rejoins — so buildAppId('X','uksb-wt64nei4u6#my-stack') now returns uksb-wt64nei4u6#my-stack verbatim (only other unsafe chars become -), then clips to APP_ID_MAX_LEN. Both branches now share the same #-separator semantics. 🤖 @scottschreckengaust (agent:w1)


test('explicit override is used verbatim (sanitized)', () => {
expect(buildAppId('stack', 'custom-value')).toBe('custom-value');
expect(buildAppId('stack', 'has/slash')).toBe('has-slash');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test locks in the bug above. has/slash -> has-slash is correct behavior, but there is no case with a #, so the override path mangling the solution separator never trips CI. Worth adding:

expect(buildAppId('stack', 'uksb-wt64nei4u6#my-stack')).toBe('uksb-wt64nei4u6#my-stack');

That assertion fails today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Added your assertion plus adversarial cases in solution-ua-aspect.test.ts: buildAppId('stack','uksb-wt64nei4u6#my-stack') -> unchanged (was failing before the fix); multi-# (my#stack) preserved; #+slash mix (uksb-wt64nei4u6#a/b -> uksb-wt64nei4u6#a-b, a/b#c/d -> a-b#c-d); and an over-length clip that still contains an earlier #. All green in a503ee7. 🤖 @scottschreckengaust (agent:w1)

function getClient(): ECSClient {
if (!sharedClient) {
sharedClient = new ECSClient({});
sharedClient = new ECSClient({ ...abcaUserAgent() });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line got the UA and getS3Client() eight lines below did not (sharedS3Client = new S3Client({})). Same file, same pattern, same PR.

It generalizes: all 8 new S3Client( sites in cdk/src are unattributed, and none appear in the admitted-gaps list:

  • confirm-uploads.ts:42
  • get-trace-url.ts:34
  • cleanup-pending-uploads.ts:50
  • shared/create-task-core.ts:107
  • shared/orchestrator.ts:511
  • shared/strategies/ecs-strategy.ts:38
  • github-webhook-processor.ts:33
  • jira-webhook-processor.ts:71

Six of those eight live in files this PR already touched. Given S3 carries attachments and trace artifacts, this is likely the highest-volume service in the solution, so the md/ plane misses its biggest contributor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Fixed all 8 new S3Client({}) sites with ...abcaUserAgent() (confirm-uploads, get-trace-url, cleanup-pending-uploads, shared/create-task-core, shared/orchestrator, shared/strategies/ecs-strategy incl. getS3Client(), github-webhook-processor, jira-webhook-processor) in a503ee7. Also attributed the co-located naked DynamoDB + Bedrock clients in jira-webhook-processor (that whole file lacked the UA). Zero naked AWS SDK clients now remain in cdk/src. No sites deferred. 🤖 @scottschreckengaust (agent:w1)

Comment thread agent/src/aws_session.py Outdated

ua_config = ua.client_config()
existing = kwargs.get("config")
kwargs["config"] = existing.merge(ua_config) if existing is not None else ua_config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The docstring says "Preserves a caller-supplied Config by merging rather than overwriting," but botocore's Config.merge(other) gives precedence to other (it does config_options.update(other._user_provided_options)). Verified against botocore 1.43.53:

Config(read_timeout=7, user_agent_extra='caller/1.0').merge(
    Config(user_agent_extra='md/uksb-wt64nei4u6#agent')
).user_agent_extra
# => 'md/uksb-wt64nei4u6#agent'   ('caller/1.0' dropped)

Non-colliding keys like read_timeout do survive, so only user_agent_extra itself is affected. No call site passes config= today, so it is latent. Note the asymmetry: the scoped-session path keeps both segments, so the two paths behave differently for the same input.

Fix would be concatenating when the caller already set an extra:

if existing is not None and existing.user_agent_extra:
    ua_config = Config(user_agent_extra=f"{existing.user_agent_extra} {static_user_agent_extra()}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Fixed in a503ee7. _merge_ua_config now checks the caller's user_agent_extra: when set, it concatenates f"{caller_extra} {static_user_agent_extra()}" into a fresh Config and merges that (so both segments survive and non-colliding keys like read_timeout still merge); when unset, our md/ config wins as before. This matches the scoped-session path, which already kept both. Docstring updated to describe the concat. 🤖 @scottschreckengaust (agent:w1)

cfg = mk.call_args.kwargs["config"]
assert cfg.user_agent_extra == "md/uksb-wt64nei4u6#agent"

def test_caller_config_is_merged_not_overwritten(self, monkeypatch):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The name promises "merged not overwritten," and the comment on line 357 says "Both the caller's setting and our UA survive the merge," but the fixture passes Config(read_timeout=7), the one key that cannot collide. The test asserts a property it never exercises.

Switching to Config(read_timeout=7, user_agent_extra='caller/1.0') and asserting 'caller/1.0' in cfg.user_agent_extra makes it fail, which is the actual behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Rewrote the test in test_aws_session.py to pass Config(read_timeout=7, user_agent_extra='caller/1.0') and assert BOTH 'caller/1.0' and 'md/uksb-wt64nei4u6#agent' are in the merged user_agent_extra (plus read_timeout == 7). This failed before the concat fix and passes now. Kept a second test for the no-collision path (Config(read_timeout=7) -> md/ segment applied). 8/8 UA tests + 18/18 aws_session tests green. 🤖 @scottschreckengaust (agent:w1)


/** The component label for this Lambda (from env, sanitized). */
function componentLabel(): string {
return sanitizeUaValue(process.env[COMPONENT_ENV]?.trim() || DEFAULT_COMPONENT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The api fallback is reasonable as a default, but it silently mislabels surfaces whose constructs never set ABCA_COMPONENT. jira-integration.ts is the only integration missing ComponentUaAspect (Slack, Linear, and github-screenshot all call it), so its 3 Lambdas report md/uksb-wt64nei4u6#api. Reachable in production: jira-webhook-processor calls into shared/create-task-core.ts, which does carry the UA, so live Jira webhook traffic is attributed as the REST API surface.

The api-key Lambdas are in the same position, since apiKeyEnv in task-api.ts is the only env object that does not spread commonEnv.

Adding Aspects.of(this).add(new ComponentUaAspect('webhook')) to jira-integration.ts matches the other three integrations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@theagenticguy Fixed in a503ee7. Added Aspects.of(this).add(new ComponentUaAspect('webhook')) to jira-integration.ts (matching slack/linear/github-screenshot), so its 3 Lambdas now report md/...#webhook instead of the #api fallback. For the api-key Lambdas: apiKeyEnv and the api-key authorizer env now set ABCA_COMPONENT: 'api' explicitly (no longer relying on the fallback), and I relabeled WebhookCreateTaskFn to webhook (it inherited createTask's api label but is a webhook surface). Added a jira-integration test asserting the webhook label and a task-api test covering the api-key/webhook split (the prior loop passed vacuously). 🤖 @scottschreckengaust (agent:w1)

scottschreckengaust and others added 8 commits July 29, 2026 04:28
…319)

Alternative to PR #338: emit the app/ segment via the SDK-native
AWS_SDK_UA_APP_ID env var (botocore + JS v3 read it automatically) using
'#' instead of '/' as the separator, so no raw-user-agent-path machinery
is needed. Each ua module owns only the static md/#{component} segment;
there is no per-request {TRACE} handle, no before-send/middleware, and no
module trace state — request correlation stays with X-Ray / structured
logs (#245), and connection pools are never re-pinned.

CloudFormation stack names are [A-Za-z0-9-], a subset of both the
UA-token and app-id charsets, so {STACKNAME} needs no sanitization; the
only sanitize() left is defensive cover on the component label.

This commit adds the three mirrored helpers + tests (agent/src/ua.py,
cdk/src/handlers/shared/ua.ts, cli/src/ua.ts). Each has a wire-capture
test asserting both app/ and md/ segments land on a real outbound
User-Agent header (and that app/ is omitted when AWS_SDK_UA_APP_ID is
unset/empty — the customer opt-out). 12 agent + 12 cdk + 10 cli tests,
100% coverage on the new modules. Wiring the client sites + CDK env
threading follow in subsequent commits.

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sites (#319)

Session-level user_agent_extra on both the scoped (refreshable) and the
plain ambient session covers every tenant_client/tenant_resource caller.
New platform_client() carries the static md/ segment (merged into any
caller Config) for the 8 direct boto3.client sites that bypass the
session by design — logs x5 (shell, server x2, telemetry x2), secrets
manager x2 (config), bedrock-agentcore x1 (memory) — plus the ambient
STS client used for role chaining.

No per-request trace handle and no before-send appender: the md/ segment
is fully static, so cached clients and the singleton session pool are
never re-pinned. The app/ segment is contributed separately by the SDK
from AWS_SDK_UA_APP_ID (threaded by CDK, next commit).

4 new aws_session tests assert the md/ segment rides platform_client,
the unscoped tenant_client, a merged caller Config, and the scoped
session. Full agent suite green (1070 tests).

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Spread ...abcaUserAgent() into all 60 SDK v3 client constructors across
43 handler files (DynamoDB/Secrets Manager/Lambda/Bedrock/ECS/
BedrockAgentCore). DocumentClient sites instrument the inner
DynamoDBClient (shared middleware stack). No withAbcaTrace/middleware —
the md/ segment is fully static, so module-level cached clients keep
their connection pools; the app/ segment rides native AWS_SDK_UA_APP_ID
(threaded next commit).

No behavior change beyond the UA header: all 2051 existing CDK tests
pass unmodified (the new spread arg merges into the constructor config
the tests already assert on / mock).

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
)

applyDefaultAppId() at startup defaults AWS_SDK_UA_APP_ID to the
solution id (only when unset — an explicit '' opts out) so the CLI's
own SDK calls carry the app/ segment with no per-site code. Spread
...abcaUserAgent() into all 18 AWS SDK v3 client sites (Cognito x3,
Secrets Manager, CloudFormation, DynamoDB) across auth/admin/github/
slack/linear; the bgagent REST ApiClient is not an AWS SDK client and
is untouched.

auth.test.ts asserts the Cognito client constructor receives the md/
customUserAgent pair. Full CLI suite green (365 + new tests).

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…319)

The `app/` segment is now SDK-native: a stack-level SolutionUaAspect sets
AWS_SDK_UA_APP_ID=uksb-wt64nei4u6#{stackName} on every Lambda (current and
future, structurally), and the AgentCore runtime + ECS container set the
same value explicitly (the Lambda-only aspect can't reach them). botocore
and JS v3 both read AWS_SDK_UA_APP_ID natively, so no client code builds
the app/ segment. `-c sdkUaAppId=''` opts the whole stack out; any other
`-c sdkUaAppId=` value overrides.

The `md/#{component}` label is per-surface ABCA_COMPONENT: 'api'
(task-api commonEnv), 'orchestr' (orchestrator/reconcilers/cleanup/fanout),
'webhook' (slack/linear/github-screenshot integrations, via a per-construct
ComponentUaAspect so every function in the integration — including future
ones — is labeled without editing each env block).

buildAppId() centralizes the value: defaults to uksb-wt64nei4u6#{stack},
sanitizes a non-CFN override, clips to the documented 50-char cap, and
returns undefined for the empty-string opt-out. CloudFormation stack names
are [A-Za-z0-9-] (already app-id-safe), so no stack-name sanitization is
needed in the default path.

New tests: solution-ua-aspect.test.ts (buildAppId vectors + both aspects);
task-api/orchestrator template assertions for the component label. Full
CDK suite green (2061 tests). Local synth fails only on the pre-existing
ec2:DescribeAvailabilityZones cred gap (CI runs the real synth).

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New "Common mistakes" bullet directing agent/handler/CLI code to the
per-surface ua helpers and explaining the app/ (SDK-native via
AWS_SDK_UA_APP_ID) vs md/ (explicit per-surface label) split, plus the
customer opt-out. Root-level file — no Starlight sync needed.

Part of #319

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uard (#319)

The UA rework swapped resolve_linear_api_token's boto3.client(...) for
platform_client(...), which imports boto3 lazily at call time. That moved the
SDK import outside the try/except ImportError guard, so a missing SDK would
raise an uncaught ImportError (propagating through the pipeline) instead of the
pre-feature graceful skip. Re-add an explicit `import boto3` availability probe
inside the guard so the "boto3 unavailable → skip Linear MCP" degradation path
is restored, while still building the client via UA-carrying platform_client.

Found during /review_pr self-review of the #345 rebase.

Closes #319

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…e S3/Jira clients (#319)

Remediate @theagenticguy's CHANGES_REQUESTED review on PR #345.

- BLOCKING: buildAppId override branch sanitized the whole string with the
  UA-token charset (which excludes '#'), mangling the canonical
  'uksb-wt64nei4u6#{stack}' separator back to '-'. Added sanitizeAppId() that
  splits on '#', sanitizes each segment, and rejoins so the separator survives;
  both branches now clip to APP_ID_MAX_LEN. Tests exercise '#' preservation,
  multi-'#', '#'+slash mix, and over-length clip.
- BLOCKING: attributed all 8 `new S3Client({})` sites in cdk/src with
  ...abcaUserAgent() (confirm-uploads, get-trace-url, cleanup-pending-uploads,
  create-task-core, orchestrator, ecs-strategy, github-webhook-processor,
  jira-webhook-processor). Also attributed the co-located naked DynamoDB +
  Bedrock clients in jira-webhook-processor (previously fully unattributed).
- SHOULD-FIX: _merge_ua_config dropped a caller's user_agent_extra because
  botocore Config.merge gives precedence to the argument. Now concatenates
  caller-extra + our md/ segment so both survive; non-colliding keys still
  merge. Rewrote the test to pass a colliding user_agent_extra (was read_timeout
  only, which never collides) and assert both segments survive.
- Added ComponentUaAspect('webhook') to jira-integration (the only integration
  missing it; matches slack/linear/github-screenshot), so its 3 Lambdas report
  md/...#webhook instead of falling back to #api.
- task-api: set ABCA_COMPONENT explicitly on apiKeyEnv + the api-key authorizer
  (api); relabeled WebhookCreateTaskFn to webhook (it inherited createTask's api
  label but is a webhook surface).
- Applied SolutionUaAspect at AspectPriority.MUTATING in main.ts so it runs
  before cdk-nag (500), matching the agent stack's aspects.
- CLI: attributed every remaining naked AWS SDK client (runtime-status,
  platform-doctor x2, webhook-test, github-token x2, dynamo-clients x2, and the
  jira command's SecretsManager/DynamoDB/CloudFormation clients).
- Tests: nested-scope synth assertion in agent.test.ts (AWS_SDK_UA_APP_ID on
  deeply-nested integration Lambdas; two CDK-internal custom-resource provider
  Lambdas are framework-owned and excluded); task-api api-key/webhook label
  coverage; jira-integration webhook-label guard.

No new dependencies (native SDK field + existing helpers).

Closes #319

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@scottschreckengaust
scottschreckengaust force-pushed the feat/319-sdk-user-agent-appid branch from ea551c7 to a503ee7 Compare July 29, 2026 04:51
@scottschreckengaust

Copy link
Copy Markdown
Contributor Author

🤖 @scottschreckengaust (agent:w1) — signing out, over and out.

DONE: PR #345 remediated per @theagenticguy's review (6 inline threads + summary nits addressed, replied in-thread), rebased onto latest origin/main, gates green, pushed (a503ee76). Body carries Closes #319. Orchestrator verifies CI + human re-requests review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(observability): inject solution into outbound AWS SDK User-Agent

2 participants