feat: add --output json flag (sam build) - #9136
Conversation
…able output Add a --output option to `sam build` that supports "text" (default, unchanged behavior) and "json" (structured output for programmatic consumers). When --output json is specified: - Success output is a JSON object with status, build_dir, template_file, and a resources array listing each built function's logical_id, runtime, and architecture. - Error output is a JSON object with status, error type, message, and the failing resource name when available. - Build progress logs are suppressed from stdout (sent to stderr only) so that stdout contains only valid JSON. This enables CI/CD pipelines, IDE extensions, and AI-assisted developer tools to consume build results programmatically without fragile text parsing.
There was a problem hiding this comment.
Code Review Results
Reviewed: 56510b1..54aed55
Files: 5
Comments: 3
Comments on lines outside the diff:
[samcli/commands/build/build_context.py:381] [BUG] In JSON mode both error handlers call sys.exit(1) instead of raising UserException. SystemExit inherits from BaseException, not Exception, so the @track_command decorator on the build CLI (see samcli/lib/telemetry/metric.py, which only catches (UserException, click.Abort, ...) and Exception) will not catch it. That means:
_send_command_run_metricsis never called for failedsam build --output jsonruns, soexit_reasonandexit_codefor these failures are silently dropped from telemetry.- The exit path diverges from every other
samcommand, which uniformly funnels errors throughUserException.
Consider raising UserException after emitting the JSON (the top-level Click framework already turns it into exit code 1), or at minimum route through click_ctx.exit(1) after ensuring telemetry has been flushed. A minimal fix:
if self._output == "json":
click.echo(json.dumps(error_result, indent=2))
# still raise so track_command records the failure;
# suppress the default click error output separately if needed
raise UserException(str(ex), wrapped_from=wrapped_from) from exThe same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.
Changed so that we raise |
- Replace sys.exit(1) with raise UserException in error handlers to
preserve telemetry tracking via @track_command decorator
- Wrap both container and in-process build paths in try/except so
resource_name is tagged on BuildError regardless of build mode
- Include layers in JSON success output alongside functions
- Rename field from logical_id to resource_id (accurate for nested stacks)
- Add type field ("function" or "layer") to each resource entry
madhavdonthula1
left a comment
There was a problem hiding this comment.
- Replace sys.exit(1) with raise UserException in error handlers to
preserve telemetry tracking via @track_command decorator - Wrap both container and in-process build paths in try/except so
resource_name is tagged on BuildError regardless of build mode - Include layers in JSON success output alongside functions
- Rename field from logical_id to resource_id (accurate for nested stacks)
- Add type field ("function" or "layer") to each resource entry
| ) | ||
| except BuildError as ex: | ||
| ex.resource_name = function_name | ||
| raise |
There was a problem hiding this comment.
[BUG] The new try/except BuildError only wraps the ZIP build path (_build_function_on_container and _build_function_in_process). It is placed inside the if packagetype == ZIP: block, so several other BuildError sources remain unwrapped and will produce JSON error payloads with no resource field — which is the PR's headline improvement:
- _build_lambda_image (called for packagetype == IMAGE at line 741) raises DockerBuildFailed at lines 440, 443, 449, 493 and DockerfileOutSideOfContext at lines 496, 505.
- _build_layer (lines 541–678) calls _build_function_on_container / _build_function_in_process for layers with no surrounding try/except.
- build_single_layer_definition in samcli/lib/build/build_strategy.py:216 raises MissingBuildMethodException (a BuildError subclass).
For an Image function or a layer that fails to build, the JSON output will be:
{
"status": "failure",
"error": {
"type": "DockerBuildFailed",
"message": "..."
}
}...with no resource key, contradicting the PR description. Consider moving the assignment closer to where the resource is known — for example, attach resource_name at the strategy layer where function.full_path / layer.full_path is already available, so it covers Zip functions, Image functions, and Layers uniformly:
try:
... # build_single_function_definition / build_single_layer_definition body
except BuildError as ex:
if ex.resource_name is None:
ex.resource_name = resource.full_path
raiseAlternatively, duplicate the current try/except in _build_lambda_image and _build_layer.
Move the try/except BuildError from app_builder._build_function (which only covered the ZIP in-process path) to DefaultBuildStrategy's build_single_function_definition and build_single_layer_definition. This ensures resource_name is tagged for ALL build paths: - ZIP functions (in-process and container) - IMAGE functions (Docker builds) - Layer builds Known limitation: BuildInsideContainerError does not inherit from BuildError (it inherits from Exception directly), so it won't get resource_name tagged by this mechanism. Fixing this would require changing the exception hierarchy which is out of scope for this PR. The error type and message are still present in JSON output for this case — only the resource field is missing. Also fixes unit test assertion to expect the new output="text" kwarg.
| """ | ||
| try: | ||
| return self._do_build_single_function_definition(build_definition) | ||
| except BuildError as ex: |
There was a problem hiding this comment.
[BUG] The new wrapper only catches BuildError, so the resource field is still missing from JSON error output for exceptions that are raised during a per-function build but are not BuildError subclasses. The outer except in build_context.py catches five additional types, three of which originate from a specific function build:
UnsupportedRuntimeException— raised inApplicationBuilder._build_functionwhen a deprecated runtime is used (app_builder.py:752).BuildInsideContainerError— raised in_build_function_on_containeron Docker image pull failures (app_builder.py:1072, 1097).UnsupportedBuilderLibraryVersionError— raised in_build_function_on_containerwhen the container is missing the builder executable (app_builder.py:1049, 1105).
For any of these, the JSON payload emitted from build_context's error handler will not contain a resource field, which is precisely the PR's headline improvement ("the text error today does not identify which function failed"). Users on the container path or on a deprecated runtime will still see anonymous JSON failures.
Broaden the wrapper to tag any exception that surfaces from the per-definition build with the failing resource, for example:
def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]:
try:
return self._do_build_single_function_definition(build_definition)
except (
BuildError,
UnsupportedRuntimeException,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
) as ex:
if getattr(ex, "resource_name", None) is None:
ex.resource_name = build_definition.get_full_path()
raiseNote that UnsupportedRuntimeException, BuildInsideContainerError, and UnsupportedBuilderLibraryVersionError do not currently define resource_name in their __init__, so either add the attribute to them (mirroring the change to BuildError) or rely on dynamic attribute assignment. The same broadening is needed for build_single_layer_definition.
… types Expand the except clause in DefaultBuildStrategy to also catch UnsupportedRuntimeException, BuildInsideContainerError, and UnsupportedBuilderLibraryVersionError. These exceptions don't inherit from BuildError but can originate from a specific function build (deprecated runtime, Docker pull failure, missing builder in container). Uses dynamic attribute assignment (ex.resource_name = ...) since these classes don't define resource_name in __init__. The consumer in build_context.py already uses getattr(ex, "resource_name", None) which handles both cases safely.
madhavdonthula1
left a comment
There was a problem hiding this comment.
broaden exception catch to add more build error types
- Apply black formatting to command.py and build_strategy.py - Fix ruff import ordering in build_strategy.py - Add type: ignore comments for mypy union-attr and index errors (dynamic resource_name assignment on non-BuildError exceptions) - Regenerate schema/samcli.json to include the new --output parameter
madhavdonthula1
left a comment
There was a problem hiding this comment.
- Apply black formatting to command.py and build_strategy.py
- Fix ruff import ordering in build_strategy.py
- Add type: ignore comments for mypy union-attr and index errors
(dynamic resource_name assignment on non-BuildError exceptions) - Regenerate schema/samcli.json to include the new --output parameter
|
|
||
| def run(self) -> None: | ||
| """Runs the building process by creating an ApplicationBuilder.""" | ||
| if self._output == "json": |
There was a problem hiding this comment.
[BUG] The unconditional logging.getLogger("samcli").setLevel(logging.WARNING) in run() silently overrides the --debug flag when a user runs sam build --debug --output json.
Flow:
- debug_option is defined with is_eager=True (
samcli/cli/options.py:26). Its callback setsctx.debug = True, which triggers the setter insamcli/cli/context.py:75and reconfigures thesamclilogger toDEBUG. - BuildContext.run() then unconditionally resets the same logger to
WARNING, suppressing every INFO/DEBUG log the user explicitly requested.
This is easy to miss because the change is silent — the user sees no warning and no debug output. It defeats the primary reason someone would combine --debug with --output json (troubleshooting an automated build).
Also note the suppression is not strictly required for "pure JSON on stdout": SAM CLI's log handlers (samcli/lib/utils/sam_logging.py:42-52) write to stderr, not stdout, so stdout is already clean of log lines regardless of level.
Two reasonable fixes:
Option A — drop the line entirely (logs go to stderr, so stdout is unaffected):
def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._is_sam_template():
SamApiProvider.check_implicit_api_resource_ids(self.stacks)Option B — only lower verbosity, never raise it above what the user asked for:
def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._output == "json":
samcli_logger = logging.getLogger("samcli")
# Don't override an explicit --debug (or any level already <= WARNING)
if samcli_logger.getEffectiveLevel() > logging.WARNING:
samcli_logger.setLevel(logging.WARNING)Either preserves clean JSON on stdout without breaking --debug.
reedham-aws
left a comment
There was a problem hiding this comment.
Overall, we need to add new tests for this output behavior (meaning, with json enabled). I think at a base level we need unit tests, not sure if we really need integration tests since the underlying behavior is not changing.
| result = { | ||
| "status": "success", | ||
| "build_dir": build_dir_in_success_message, | ||
| "template_file": output_template_path_in_success_message, | ||
| "resources": resources, |
There was a problem hiding this comment.
One thing is that in the old message, we had something like:
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
Which I think is actually kind of useful for agents. I wonder if we can simplify and put that into the result? Might be doing too much and I don't really think it's to important, so up to you/other reviewers.
There was a problem hiding this comment.
Yeah this is a valid point and I actually explored it previously as a possible extension, but for sam deploy output. I tested three different types of extensions including one where next_steps was the main feature and while checking the benchmarks I noticed it did not improve the agent experience and in fact caused to add more tools called. The agent already has the inputs. It knows which flags it passed, and it knows the standard SAM commands. But to be honest I have not tested this with sam build so I'm open to doing that if anyone still finds that as something that would work specifically for sam build because theres definitely a lot more next steps for sam build.
| Scenario | With extension | Without |
|---|---|---|
Edge-layer 403 debug (defined_routes, Opus) |
13 calls / 363s | 10 calls / 215s |
Handler-mismatch debug (function_readiness, Opus) |
27 calls / 461s | 13 calls / 240s |
| Happy-path deploy (Opus) | 6 calls | 7 calls |
| IAM-permission debug (Opus) | 18 calls | 18 calls |
There was a problem hiding this comment.
I'd be curious to know what tools are actually being called after the sam deploy. What were you recommending the agent do next? Some of those might have been worthwhile so not sure if raw number of calls being higher is necessarily bad.
That being said, I think you're right that the agent probably knows enough to do those anyway. We can leave it.
| UnsupportedRuntimeException, | ||
| BuildError, | ||
| BuildInsideContainerError, | ||
| UnsupportedBuilderLibraryVersionError, | ||
| InvalidBuildGraphException, | ||
| ResourceNotFound, |
There was a problem hiding this comment.
There are 6 different exceptions here, but looking in build_strategy.py shows that we only potentially add resource name to 4 of them. Why is that?
There was a problem hiding this comment.
One of the exceptions that we do not add resource_name extension to isInvalidBuildGraphException which has no resource to find. It is raised inbuild_graph.py:648 when a build definition has zero functions in it, so there is nothing to name. get_full_path() reads self.functions[0], which would fail on the same empty list that caused the exception in the first place.
The other one is ResourceNotFound. That is raised in build_context.py:1361 inside collect_build_resources(), which runs before the build starts. So build_strategy.py is never entered and adding it to that tuple would be dead code. It also means no resource matched the identifier, so there is no build definition to read a name from.
Honestly I should've listed this information in the PR and will start doing that whenever I come across something that has differing cases.
| def __init__(self, wrapped_from: str, msg: str, resource_name: Optional[str] = None) -> None: | ||
| self.wrapped_from = wrapped_from | ||
| self.resource_name = resource_name | ||
| Exception.__init__(self, msg) |
There was a problem hiding this comment.
Is there a reason we're not adding a resource_name field to other error types that could exist?
There was a problem hiding this comment.
Yep this is valid. I dropped it. The initial thought was that a raise site might want to pass the resource in at construction, so I made it a proper field onBuildError. But in practice nothing does: build_strategy.py sets ex.resource_name at runtime for all four exception types, so the declared param was never actually used.
| "status": "failure", | ||
| "error": { | ||
| "type": "FunctionNotFound", | ||
| "message": str(function_not_found_ex), |
There was a problem hiding this comment.
I feel like it's not good to inconsistently apply the resource field. Is there a way that you can included the missing resource here even if it's not real?
There was a problem hiding this comment.
This is valid. I changed it so that it rather populates the resource or says null so that we dont have to be inconsistent.
| ) | ||
|
|
||
| click.secho(msg, fg="yellow") | ||
| if self._output == "json": |
There was a problem hiding this comment.
I would prefer that this whole block about printing gets moved to a method just to keep the BuildContext.run simple.
There was a problem hiding this comment.
Good catch! After looking into it I realized the run() method increased by 50 lines by just adding the json output code blocks. I just made two separate methods _print_build_success and _print_build_failure that I placed under the '=_gen_success_msg so that the output-related code can stay together. I think this should improve readability and will make sure to keep this in mind when developing further.
- Move --output into a shared @output_option decorator in _utils/options.py so deploy/init can reuse it instead of redeclaring the option - Give --output its own "Output Options" help group rather than filing it under Build Strategy, which is for options that change how the build runs - Extract the JSON/text reporting out of BuildContext.run() into _print_build_success and _print_build_failure - Collect get_resources_to_build() once in run() and reuse it - Always emit the "resource" key in error output (null when the failure is not attributable to a single resource) so the schema is predictable - Hoist the wrapped_from lookup to the top of the except block instead of computing it in both branches - Drop the unused resource_name param from BuildError; build_strategy sets the attribute at runtime for all handled exception types
madhavdonthula1
left a comment
There was a problem hiding this comment.
Changes made based on comments
reedham-aws
left a comment
There was a problem hiding this comment.
Looks good to me now, but we still need tests that actually touch the behavior.
Cover _print_build_success and _print_build_failure across JSON and text modes: resource list shapes for functions/layers, architecture edge cases, the always-present "resource" error key, error-type passthrough, and the text-banner suppression path. 23 tests following the existing TestBuildContext_<method> convention.
| return f | ||
|
|
||
|
|
||
| def output_click_option(): |
There was a problem hiding this comment.
These are the same function names as the existing output_click_option / output_option in samcli/commands/list/cli_common/options.py, but with different choices (text|json vs json|table) and a different default. Two identically-named output_option symbols is an import-confusion trap.
We now have four --output conventions: json|table (list), the OutputOption enum (observability), RemoteInvokeOutputFormat (remote invoke), and this one.
Since this project is adding --output json across all commands, worth settling here since every following PR will copy it. Ideal end state is one shared contract, a full JSON view for agents and text for humans, which is what OutputOption (text/json) already encodes; list's json|table is the outlier and can converge later. Minimum ask: don't shadow the existing name.
| resources_to_build: ResourcesToBuildCollector | ||
| The functions and layers that were built | ||
| """ | ||
| if self._output == "json": |
There was a problem hiding this comment.
output is threaded through as a str and compared against the "json" literal here and again at line 1148, where it's negated (!= "json"). A typo in either silently selects the wrong mode and no test would catch it. click.Choice only validates the CLI entry point; BuildContext is also constructed directly in tests and by other callers.
samcli/lib/observability/util.py already defines the enum, and samcli/commands/logs/command.py:186 shows the conversion pattern:
# command.py -- convert once at the boundary
output=OutputOption(output)
# build_context.py -- typed, and both call sites become positive checks
if self._output is OutputOption.json:That drops the string literals from this file and removes the negated comparison on the failure path.
| click.secho("\nBuild Failed", fg="red") | ||
| return | ||
|
|
||
| # The resource key is always present so consumers can rely on the schema. It is |
There was a problem hiding this comment.
The "always present" guarantee doesn't quite hold. InvalidBuildDirException is a UserException, not a BuildError subclass, so it's caught by neither handler in run() — sam build --output json with a bad --build-dir emits no JSON at all (empty stdout, exit 1).
Either widen the catch or soften the comment so consumers don't rely on a schema that isn't guaranteed.
|
|
||
| error: Dict[str, Any] = {"type": error_type, "message": str(ex), "resource": resource_name} | ||
|
|
||
| click.echo(json.dumps({"status": "failure", "error": error}, indent=2)) |
There was a problem hiding this comment.
Minor: indent=2 pretty-prints. Given the stated motivation is agent consumption, single-line JSON is often preferred for machine parsing. Intentional?
| UnsupportedBuilderLibraryVersionError, | ||
| ) as ex: | ||
| if getattr(ex, "resource_name", None) is None: | ||
| ex.resource_name = build_definition.get_full_path() # type: ignore[union-attr] |
There was a problem hiding this comment.
The PR description says samcli/lib/build/exceptions.py "adds an optional resource_name field to BuildError", but that file isn't in the diff — so the attribute is being attached dynamically here (hence the # type: ignore[union-attr]).
This works today only because none of these exceptions define __slots__. Adding one later, a routine perf change, would break failure attribution silently at runtime in an error path.
Worth noting the four caught types share no base but Exception (UnsupportedRuntimeException, BuildInsideContainerError and UnsupportedBuilderLibraryVersionError are not BuildError subclasses), so the fix as described would only cover one of four. A class-level default on each keeps constructors untouched and lets mypy check it:
class BuildError(Exception):
resource_name: Optional[str] = NoneWas exceptions.py meant to be included in this PR?
| self.assertEqual(msg, expected_msg) | ||
|
|
||
|
|
||
| class TestBuildContext_print_build_success(TestCase): |
There was a problem hiding this comment.
These tests call _print_build_success / _print_build_failure directly, so the user-facing contract isn't covered: that run() in JSON mode emits exactly one parseable document on stdout and nothing else. One test doing json.loads(stdout) through run() would cover more than the ~15 helper tests here — and would have caught the InvalidBuildDirException gap noted in build_context.py.
| "type": "string", | ||
| "description": "Name or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network." | ||
| }, | ||
| "output": { |
There was a problem hiding this comment.
Is this file generated? I couldn't find a make schema target that regenerates it, but if one exists this should be regenerated rather than hand-edited.
| "resource_id": function.full_path, | ||
| "type": "function", | ||
| "runtime": function.runtime, | ||
| "architecture": function.architectures[0] if function.architectures else None, |
There was a problem hiding this comment.
This reports "architecture": null for the common case of a template that doesn't set Architectures, even though the artifact was built for x86_64.
Function.architecture (samcli/lib/providers/provider.py:194) already resolves this, defaulting to X86_64 when the property is absent — and it's the value the build actually used, since ApplicationBuilder._get_build_graph() passes function.architecture into FunctionBuildDefinition (app_builder.py:293).
So function.architectures[0] if function.architectures else None diverges from what was built in two ways: null instead of x86_64 when absent, and silently reporting the first element of a multi-element list that Function.architecture would reject as invalid. A consumer using this to select a Lambda architecture or container image gets the wrong answer for any template that omits Architectures.
"architecture": function.architecture,Safe to call here — the success path is only reached after _get_build_graph() already evaluated function.architecture without raising.
Note test_json_architecture_defaults_to_none_when_absent and test_json_multi_architecture_reports_first currently assert the present behaviour, so they'd need updating too.
| @@ -286,12 +291,12 @@ def run(self) -> None: | |||
| # if self._mount_with is NOT WRITE | |||
| # check the need of mounting with write permissions and prompt user to enable it if needed | |||
| mount_with_write = prompt_user_to_enable_mount_with_write_if_needed( | |||
There was a problem hiding this comment.
Two interactive prompts in the build path write to stdout, so they interleave with the JSON document and make it unparseable:
prompt_user_to_enable_mount_with_write_if_needed→click.confirm(...)atsamcli/commands/build/utils.py:106, noerr=(defaults toFalse). Triggered bysam build --use-containerfor any workflow withmust_mount_with_write_in_container— which includes all the dotnet ones._check_build_method_experimental_flag()(line 320) →prompt_experimental→click.confirm(Colored().yellow(prompt), default=False)atsamcli/commands/_utils/experimental.py:275.
Confirmed click.confirm() without err=True writes the prompt text to stdout, and it does so before the confirm aborts in a non-interactive environment, so stdout is corrupted either way.
The rest of the CLI already keeps human-facing output off stdout — LOG handlers go to stderr, and both the telemetry prompt (samcli/cli/main.py:161) and the update notice (samcli/lib/utils/version_checker.py:94-96) pass err=True. Following that here keeps stdout reserved for the JSON:
if click.confirm(
f"\nBuilding functions with {config.language} inside containers needs "
...
err=True,
):Worth a test that runs --use-container --output json and asserts stdout is still parseable.
Before
Error output is similarly unstructured:
Note: The error message does not identify which function failed.
After
Success output:
{ "status": "success", "build_dir": ".aws-sam/build", "template_file": ".aws-sam/build/template.yaml", "resources": [ {"resource_id": "OrderProcessorFunction", "type": "function", "runtime": "python3.12", "architecture": "x86_64"}, {"resource_id": "SharedDepsLayer", "type": "layer", "compatible_runtimes": ["python3.12"]} ] }Failure output (note the
resourcefield — the text error today does not identify which function failed):{ "status": "failure", "error": { "type": "WorkflowFailedError", "message": "PythonPipBuilder:ResolveDependencies - Could not satisfy the requirement: nonexistent-package==1.0.0", "resource": "PaymentHandlerFunction" } }File Changes
samcli/commands/build/command.py--outputClick option accepting"text"(default) or"json". Passes the value throughcli()→do_cli()BuildContext.samcli/commands/build/build_context.pyself._outputUserExceptionto preserve telemetry trackingsamcli/commands/build/core/options.pyRegisters
"output"inBUILD_STRATEGY_OPTIONSso it appears in--help.samcli/lib/build/build_strategy.pyWraps
DefaultBuildStrategy.build_single_function_definitionandbuild_single_layer_definitionwith try/except to tagresource_nameon any build exception. CatchesBuildError,UnsupportedRuntimeException,BuildInsideContainerError, andUnsupportedBuilderLibraryVersionError— covering ZIP, Image, container, and layer build paths uniformly.samcli/lib/build/exceptions.pyAdds an optional
resource_namefield toBuildError. Defaults toNone; existing raisers are unaffected.Benchmark Notes
Adding
--output jsontosam builddoes not dramatically change agent performance metrics. Tokens, tool calls, and time are roughly equivalent between text and JSON for this command. This is expected:sam buildis a relatively simple command with clear, short output, and LLM agents parse both formats effectively. We implemented it to keep scope consistent across the project (which adds--output jsonto all major SAM CLI commands), and because the structured error output with theresourcefield provides explicit failure attribution that text mode lacks.resourcefieldjson.loads()directlyScreenshots