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
Summary
Add
--output jsonsupport tosam buildBefore
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": [ {"logical_id": "OrderProcessorFunction", "runtime": "python3.12", "architecture": "x86_64"}, {"logical_id": "NotificationFunction", "runtime": "python3.12", "architecture": "arm64"} ] }Failure output (note the
resourcefield — the text error today does not identify which functionfailed):
{ "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(+11)Adds the
--outputClick option. Defaults the new parameter to"text"so existing callers andtests are unaffected.
samcli/commands/build/build_context.py(+93, -24)self._outputthe colored "Build Succeeded" banner
FunctionNotFounderror: emit a JSON error object (type, message) andsys.exit(1)instead ofraising
UserExceptionfor Click to render as textBuildErrorand other build failures: emit a JSON error object (type, message, resource) andsys.exit(1)— theresourcefield identifies which function caused the failuresamcli/lib/build/app_builder.py(+17, -13)_build_function(): catchBuildErrorfrom_build_function_in_process(), tag it withex.resource_name = function_name, then re-raise_build_function()is the one level that has both the error and the function identitysamcli/lib/build/exceptions.py(+4, -1)Adds an optional
resource_namefield toBuildErrorso the exception can carry the failing function'slogical ID up to the command layer. Defaults to
None; existing raisers are unaffected.Benchmark Notes
Adding
--output jsontosam builddoes not dramatically change agent performance metrics. Tokens, toolcalls, 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 formatseffectively. We implemented it to keep scope consistent across the project (which adds
--output jsontoall major SAM CLI commands), and because the structured error output with the
resourcefield providesexplicit failure attribution that text mode lacks.
resourcefieldjson.loads()directlyScreenshots