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