Stream Kudu build logs during deployment#33550
Conversation
|
Validation for Azure CLI Full Test Starting...
Thanks for your contribution! |
|
Validation for Breaking Change Starting...
Thanks for your contribution! |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds configurable build log verbosity for OneDeploy-based deployments and improves deployment UX by streaming/formatting Kudu build output and auto-expanding logs on failure.
Changes:
- Introduces
--build-logs {summary|full|none}and threads it through OneDeploy deploy flows. - Adds Kudu build log fetching/streaming, phase-based status output, and failure messages enriched with full build logs.
- Adds a new build log formatter module for summary/full/none behaviors and final URL formatting.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| src/azure-cli/azure/cli/command_modules/appservice/custom.py | Threads build_logs through deployment tracking, streams Kudu logs, and enriches failure output with build logs. |
| src/azure-cli/azure/cli/command_modules/appservice/_params.py | Adds --build-logs CLI argument (currently only in one argument context). |
| src/azure-cli/azure/cli/command_modules/appservice/_build_log_formatter.py | New formatter for summary/full/none build log filtering and output formatting helpers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,4 @@ | |||
| # -------------------------------------------------------------------------------------------- | |||
| # -------------------------------------------------------------------------------------------- | |||
| def _emit_build_log_line(message, log_formatter, timestamp=None, indent=False): | ||
| """Emit a single build log line through the formatter. | ||
|
|
||
| If formatter is None or verbosity is 'full', prints directly. | ||
| Otherwise, the formatter decides whether to show, suppress, or aggregate. | ||
|
|
||
| The formatter classifies based on the raw message text (without timestamp prefix) | ||
| to ensure patterns match regardless of how the line is formatted for display. | ||
| """ | ||
| if indent or not timestamp: | ||
| display_line = f" {message}\n" | ||
| else: | ||
| display_line = f"{timestamp} {message}\n" | ||
|
|
||
| if log_formatter is None: | ||
| logger.warning("%s", display_line.rstrip('\n')) | ||
| return | ||
|
|
||
| # Pass message with indent prefix for classification (patterns expect leading whitespace) | ||
| classify_line = f" {message}\n" | ||
| formatted = log_formatter.format_log_line(classify_line) | ||
| if formatted is not None: | ||
| # Show the display_line (with timestamp) not the classify_line | ||
| logger.warning("%s", display_line.rstrip('\n')) |
There was a problem hiding this comment.
Addressed this, the code now actually prints the text when the formatter gives a new line.
| def _fetch_kudu_log_entries(cmd, resource_group_name, webapp_name, slot, deployment_id): | ||
| """Fetch raw log entries from Kudu deployment log API. | ||
|
|
||
| Returns a list of (message, log_time, detail_entries) tuples, or None if fetching fails. | ||
| Each detail_entries is a list of (message, log_time) tuples from the details_url. | ||
| """ | ||
| import requests | ||
| from azure.cli.core.util import should_disable_connection_verify | ||
|
|
||
| try: | ||
| headers = get_scm_site_headers(cmd.cli_ctx, webapp_name, resource_group_name, slot) | ||
| scm_url = _get_scm_url(cmd, resource_group_name, webapp_name, slot) | ||
| log_url = scm_url + f'/api/deployments/{deployment_id}/log' | ||
|
|
||
| response = requests.get(log_url, headers=headers, | ||
| verify=not should_disable_connection_verify(), | ||
| timeout=15) | ||
|
|
||
| if response.status_code != 200: | ||
| return None | ||
|
|
||
| log_entries = response.json() | ||
| if not isinstance(log_entries, list): | ||
| return None | ||
|
|
||
| results = [] | ||
| for entry in log_entries: | ||
| message = (entry.get('message') or '').rstrip() | ||
| log_time = entry.get('log_time') | ||
| entry_id = entry.get('id') or log_time or message | ||
| details_url = entry.get('details_url') | ||
|
|
||
| detail_items = [] | ||
| if details_url: | ||
| try: | ||
| detail_response = requests.get(details_url, headers=headers, | ||
| verify=not should_disable_connection_verify(), | ||
| timeout=10) | ||
| if detail_response.status_code == 200: | ||
| detail_entries = detail_response.json() | ||
| if isinstance(detail_entries, list): | ||
| for detail in detail_entries: | ||
| detail_msg = (detail.get('message') or '').rstrip() | ||
| detail_time = detail.get('log_time') | ||
| detail_id = detail.get('id') | ||
| if detail_msg: | ||
| detail_items.append((detail_msg, detail_time, detail_id)) | ||
| except Exception: # pylint: disable=broad-except | ||
| pass | ||
|
|
||
| results.append((message, log_time, entry_id, detail_items)) |
There was a problem hiding this comment.
fixed it. Now it remembers which entries are already final ( details_complete_ids ) and only fetch details once per entry. The only one we re-fetch is the very last entry (because it's still being written).
|
|
||
| error_text += "Please check the build logs for more info: {}\n".format(deployment_logs) | ||
| raise CLIError(error_text) | ||
| time.sleep(15) |
There was a problem hiding this comment.
Fixed. The pause is now 5s during the build and 15s afterward.
| def _display_build_logs_on_sync_failure(params, scm_url): | ||
| """For sync deployment failures, fetch build logs and return formatted error text. | ||
|
|
||
| This handles the case where the Kudu POST returns an error (e.g., 400) after the build | ||
| completed on the server. Without this, the customer only sees 'Status Code: 400' with no | ||
| build output. | ||
|
|
||
| Returns a formatted error string with logs included, or None if logs can't be fetched. | ||
| """ | ||
| import requests | ||
| from azure.cli.core.util import should_disable_connection_verify | ||
| from azure.cli.command_modules.appservice._build_log_formatter import format_build_failure_with_logs | ||
|
|
||
| try: | ||
| headers = get_scm_site_headers(params.cmd.cli_ctx, params.webapp_name, | ||
| params.resource_group_name, params.slot) | ||
|
|
||
| # Get the latest deployment ID | ||
| latest_url = scm_url + "/api/deployments/latest" | ||
| resp = requests.get(latest_url, headers=headers, | ||
| verify=not should_disable_connection_verify(), timeout=15) | ||
| if resp.status_code != 200: | ||
| return None | ||
|
|
||
| deployment_info = resp.json() | ||
| deployment_id = deployment_info.get('id') | ||
| if not deployment_id: | ||
| return None | ||
|
|
||
| # Fetch build logs | ||
| full_logs = _fetch_full_build_logs(params.cmd, params.resource_group_name, | ||
| params.webapp_name, params.slot, deployment_id) | ||
| if not full_logs: | ||
| return None | ||
|
|
||
| # Format in the same style as async BuildFailed | ||
| error_text = "Deployment failed because the build process failed\n" | ||
| return format_build_failure_with_logs(error_text, full_logs) |
There was a problem hiding this comment.
#33658 - created a new PR for this one, In the PR we get the deployment id from the response
| c.argument('enriched_errors', options_list=['--enriched-errors'], | ||
| help='If true, deployment failures will show context-enriched diagnostics with error codes, suggested fixes, and Copilot prompts.', | ||
| arg_type=get_three_state_flag()) | ||
| c.argument('build_logs', options_list=['--build-logs'], | ||
| help='Controls verbosity of build log output during deployment. ' | ||
| '"summary" (default): shows phases, milestones, and aggregated warnings. ' | ||
| '"full": shows all raw build logs. ' | ||
| '"none": suppresses build logs entirely (auto-expands on failure).', | ||
| choices=['full', 'summary', 'none'], default='summary') | ||
|
|
||
| with self.argument_context('functionapp deploy') as c: |
| """Fetch raw log entries from Kudu deployment log API. | ||
|
|
||
| Returns a list of (message, log_time, detail_entries) tuples, or None if fetching fails. | ||
| Each detail_entries is a list of (message, log_time) tuples from the details_url. | ||
| """ |
|
webapp |
|
|
||
| # --- Patterns for classification --- | ||
|
|
||
| # Oryx internal metadata lines (hidden in summary mode) |
There was a problem hiding this comment.
Is this something we are going to need to continually maintain as we update oryx?
There was a problem hiding this comment.
These patterns are just for prettifying the output - If Oryx changes its wording and a pattern stops matching, that line just shows up as normal output instead of a highlighted milestone.
|
|
||
| if _should_enrich_errors and response.status_code >= 400: | ||
| logger.error("Deployment failed. Visit %s to get more information about your deployment.", | ||
| latest_deploymentinfo_url) |
There was a problem hiding this comment.
For this, should we check to see if we are enrichming errors before we raise the CLIError at line 11686?
| latest_deploymentinfo_url = scm_url + "/api/deployments/latest" | ||
|
|
||
| # For sync deployment failures, fetch and display build logs in the error message | ||
| build_failure_text = _display_build_logs_on_sync_failure(params, scm_url) |
There was a problem hiding this comment.
If this fails, do we know for certain that it was the build which failed and not something else?
There was a problem hiding this comment.
This PR handles only async deployments. This is not relevant anymore.
For async deployments, if it fails, we display whole logs
bccd2e2 to
d01c651
Compare
Description
az webapp deploy currently gives no visibility into the server-side build. This PR streams Kudu's build logs into the terminal during async deployments and prints full logs inline on failure.
A new --build-logs flag controls verbosity:
• summary (default): phase markers, key milestones, and aggregated package counts; transient output collapses to a single status line.
• full : raw, unfiltered build output.
• none : build logs suppressed.
During the build, we poll Kudu's log endpoint every ~5s and print new lines sequentially (de-duplicated). So, logs arrive in 5s batches. Fast builds that finish between polls print everything at once on completion; after the build, polling slows to ~15s for runtime startup.
Changes:
Before this Change:

Successful deployment:
Failed Deployment:

After this Change:

Successful deployment: (Default is --build-logs summary)
All non-milestone output is transient — it streams onto a single self-overwriting status line that updates in place, so progress stays visible without flooding the terminal.
Failed Deployment: (Displays full logs)


With --build-logs full:

With --build-logs none:

Testing:
Unit tests - test_build_log_formatter.py (24 tests): verbosity modes, pip/npm aggregation, the renderer (status line, truncation, finalize, TTY detection, pacing, and Kudu log-fetch dedup. All passing.
Manual - Verified on a live Linux web app across Python, Node, PHP, and .NET: all three --build-logs modes, async vs. sync (sync streams nothing), and the build-failure auto-expand.