Skip to content

feat: add --output json flag (sam build)#9136

Open
madhavdonthula1 wants to merge 5 commits into
aws:developfrom
madhavdonthula1:feat/build-output-json-clean
Open

feat: add --output json flag (sam build)#9136
madhavdonthula1 wants to merge 5 commits into
aws:developfrom
madhavdonthula1:feat/build-output-json-clean

Conversation

@madhavdonthula1

Copy link
Copy Markdown
Contributor

Summary

Add --output json support to sam build

Before

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
...

Error output is similarly unstructured:

Build Failed
Error: PythonPipBuilder:ResolveDependencies - Could not satisfy the requirement: nonexistent-package==1.0.0

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 resource field — 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 (+11)

Adds the --output Click option. Defaults the new parameter to "text" so existing callers and
tests are unaffected.

samcli/commands/build/build_context.py (+93, -24)

  • Store the output format as self._output
  • Suppress samcli INFO-level logs in JSON mode so stdout is pure JSON
  • On success: emit a structured JSON object (status, build_dir, template_file, resources list) instead of
    the colored "Build Succeeded" banner
  • On FunctionNotFound error: emit a JSON error object (type, message) and sys.exit(1) instead of
    raising UserException for Click to render as text
  • On BuildError and other build failures: emit a JSON error object (type, message, resource) and
    sys.exit(1) — the resource field identifies which function caused the failure
  • Text mode follows the exact same code paths as before

samcli/lib/build/app_builder.py (+17, -13)

  • In _build_function(): catch BuildError from _build_function_in_process(), tag it with
    ex.resource_name = function_name, then re-raise
  • This is needed because the error originates deep in the builder where the function name isn't known.
    _build_function() is the one level that has both the error and the function identity

samcli/lib/build/exceptions.py (+4, -1)

Adds an optional resource_name field to BuildError so the exception can carry the failing function's
logical ID up to the command layer. Defaults to None; existing raisers are unaffected.

Benchmark Notes

Adding --output json to sam build does 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 build is 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 json to
all major SAM CLI commands), and because the structured error output with the resource field provides
explicit failure attribution that text mode lacks.

Metric Text Output JSON Output
Tokens (avg per task) ~28,800 ~28,400
Tool calls (error diagnosis) 5 4
Time (avg) ~180s ~165s
Error attribution Inferred from log line ordering Explicit resource field
Programmatic parsing Regex/sed required json.loads() directly

Screenshots

Screenshot 2026-07-20 at 12 46 40 PM Screenshot 2026-07-20 at 12 47 18 PM Screenshot 2026-07-20 at 12 47 45 PM

…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.
@madhavdonthula1
madhavdonthula1 requested a review from a team as a code owner July 20, 2026 19:48
@github-actions github-actions Bot added area/build sam build command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Jul 20, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_metrics is never called for failed sam build --output json runs, so exit_reason and exit_code for these failures are silently dropped from telemetry.
  • The exit path diverges from every other sam command, which uniformly funnels errors through UserException.

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 ex

The same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.

Comment thread samcli/lib/build/app_builder.py Outdated
Comment thread samcli/commands/build/build_context.py Outdated
@madhavdonthula1

Copy link
Copy Markdown
Contributor Author

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_metrics is never called for failed sam build --output json runs, so exit_reason and exit_code for these failures are silently dropped from telemetry.
  • The exit path diverges from every other sam command, which uniformly funnels errors through UserException.

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 ex

The same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.

Changed so that we raise UserException now instead of exiting using sys.exit(1)

- 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 madhavdonthula1 left a comment

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.

  • 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

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 56510b1..2fd6125
Files: 5
Comments: 1

Comment thread samcli/lib/build/app_builder.py Outdated
)
except BuildError as ex:
ex.resource_name = function_name
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 56510b1..b104652
Files: 6
Comments: 1

Comment thread samcli/lib/build/build_strategy.py Outdated
"""
try:
return self._do_build_single_function_definition(build_definition)
except BuildError as ex:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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 in ApplicationBuilder._build_function when a deprecated runtime is used (app_builder.py:752).
  • BuildInsideContainerError — raised in _build_function_on_container on Docker image pull failures (app_builder.py:1072, 1097).
  • UnsupportedBuilderLibraryVersionError — raised in _build_function_on_container when 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()
       raise

Note 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 madhavdonthula1 left a comment

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.

broaden exception catch to add more build error types

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

Labels

area/build sam build command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant