Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ def _render_settings_table(settings):
sname='Setting', sw=setting_w, vname='Value', vw=value_w, dname='Details')
_row((Style.HIGHLIGHT, header))
_row((Style.SECONDARY, '{s}{v}{d}'.format(
s=('─' * (setting_w - 2)).ljust(setting_w),
v=('─' * (value_w - 2)).ljust(value_w),
d='─' * 40)))
s=('-' * (setting_w - 2)).ljust(setting_w),
v=('-' * (value_w - 2)).ljust(value_w),
d='-' * 40)))

for setting in settings:
name = str(setting.get('Setting') or '')
Expand All @@ -178,7 +178,7 @@ def _render_config_checks(payload, config_check, settings):
if payload.get('configCheck') is not None:
_render_snapshot_metadata(payload, config_check)
_out()
_row((Style.HIGHLIGHT, '═══ BUILT-IN CHECKS ' + '═' * 55))
_row((Style.HIGHLIGHT, '=== BUILT-IN CHECKS ' + '=' * 55))
_out()
if payload.get('configCheck') is None:
if payload.get('configCheckStatus') == 404:
Expand All @@ -201,7 +201,7 @@ def _render_config_checks(payload, config_check, settings):
def _render_runtime_error(runtime_error):
_out()
_out()
_row((Style.HIGHLIGHT, '═══ SITE RUNTIME ERROR RECOMMENDATION ' + '═' * 37))
_row((Style.HIGHLIGHT, '=== SITE RUNTIME ERROR RECOMMENDATION ' + '=' * 37))
_out()
timestamp_raw = runtime_error.get('lastErrorTimestamp')
timestamp = _format_dt(timestamp_raw) or str(timestamp_raw or '')
Expand All @@ -228,7 +228,7 @@ def _render_hints(payload, any_issue):
slot = payload.get('slot')
slot_arg = ' --slot {}'.format(slot) if slot else ''
_out()
_out((Style.WARNING, '▶ Hint:'))
_out((Style.WARNING, 'Hint:'))
if any_issue:
_out(' Update flagged app setting: az webapp config appsettings set -n {} -g {}{} '
'--settings KEY=VALUE'.format(site_name, resource_group, slot_arg))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
from azure.cli.core.style import Style, print_styled_text


_MAX_ORPHAN_STARTUPS = 5

@aamos-company aamos-company Oct 9, 2026 •

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.

Now limit stale instances to 5 but provides link to view full summary. Special character removed before "Hint"

Image



def _emit_header(instances, orphan_startups, app_name, emit):
"""Emit the top-of-report header. Returns False when there's nothing to render
(empty instances AND no orphan startups) so the caller can bail out early."""
Expand Down Expand Up @@ -99,7 +102,7 @@ def _emit_orphan_startup(orphan, emit):
container's logs, but ARM has already replaced the worker-slot ID."""
scm_id = orphan.get('InstanceId') or '<unknown>'
emit((Style.HIGHLIGHT, 'Instance {} Startup Summary'.format(scm_id)))
emit([(Style.HIGHLIGHT, '─' * 76)])
emit([(Style.HIGHLIGHT, '-' * 76)])
emit((Style.HIGHLIGHT, 'Startup summary (last 24h)'))
_print_startup_block(orphan.get('Startup'), emit)
emit()
Expand All @@ -115,7 +118,7 @@ def _emit_hint_footer(instances, app_name, resource_group, emit):
if not has_error:
return
rg = resource_group or '<resource-group>'
emit((Style.WARNING, '▶ Hint:'))
emit((Style.WARNING, 'Hint:'))
emit(' Check application logs: az webapp log tail -n {} -g {}'.format(app_name, rg))
emit(' Check startup logs: az webapp log startup show -n {} -g {}'.format(app_name, rg))

Expand All @@ -127,6 +130,7 @@ def render_report(payload):
orphan_startups = payload.get('orphanStartups') or []
app_name = payload.get('name') or '<webapp>'
resource_group = payload.get('resourceGroup')
startup_summary_url = payload.get('startupSummaryUrl')

def emit(*objs):
print_styled_text(*objs, file=sys.stdout)
Expand All @@ -140,7 +144,15 @@ def emit(*objs):
for inst in instances:
_emit_instance_section(inst, emit)

for orphan in orphan_startups:
if orphan_startups:
emit((Style.HIGHLIGHT,
'Below startup records are from instances that no longer host the application '
'but occurred within the last 24 hours. Limited to 5 instances.'))
if startup_summary_url:
emit((Style.HIGHLIGHT, 'To review the full 24-hour history, visit {}.'.format(startup_summary_url)))
emit()

for orphan in orphan_startups[:_MAX_ORPHAN_STARTUPS]:
_emit_orphan_startup(orphan, emit)

_emit_hint_footer(instances, app_name, resource_group, emit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def _dash(value):

def _print_hint(app=app_name, rg=resource_group):
_sys.stderr.write(
'\n\u25b6 Hint:\n'
'\nHint:\n'
' Check application logs: az webapp log tail -n {name} -g {rg}\n'
' Check startup logs: az webapp log startup show -n {name} -g {rg}\n'
.format(name=app, rg=rg))
Expand Down
22 changes: 14 additions & 8 deletions src/azure-cli/azure/cli/command_modules/appservice/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -7737,6 +7737,9 @@ def _fetch_site_runtime_items(cmd, subscription_id, resource_group, name, slot_s
return runtime_items


_MAX_ORPHAN_STARTUPS = 5


def _parse_startup_summary_body(body, target_machine):
"""Parse the JSON body returned by KuduLite /api/startuplogs/summary.
Returns (startup_by_machine, plaintext_notice_or_None)."""
Expand Down Expand Up @@ -7766,7 +7769,8 @@ def _parse_startup_summary_body(body, target_machine):
def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_items):
"""Call KuduLite /api/startuplogs/summary once for the app. When --instance is set
and we have a matching runtime item, forward the machine filter so KuduLite only
walks that worker's log directory. Returns (startup_by_machine, app_wide_fetch_status)."""
walks that worker's log directory. Returns
(startup_by_machine, app_wide_fetch_status, startup_summary_url)."""
import requests
scm_url = _get_scm_url(cmd, resource_group, name, slot)
headers = get_scm_site_headers(cmd.cli_ctx, name, resource_group, slot)
Expand All @@ -7775,7 +7779,8 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_
if instance and len(runtime_items) == 1:
target_machine = runtime_items[0].get('machineName')

summary_url = '{}/api/startuplogs/summary'.format(scm_url)
startup_summary_url = '{}/api/startuplogs/summary'.format(scm_url)
summary_url = startup_summary_url
if target_machine:
summary_url = '{}?instance={}'.format(summary_url, quote(target_machine, safe=''))

Expand All @@ -7786,7 +7791,7 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_
logger.warning("Failed to call '%s': %s", summary_url, ex)
return startup_by_machine, (
"Failed to reach SCM startup summary endpoint ({}: {}).".format(
ex.__class__.__name__, ex))
ex.__class__.__name__, ex)), startup_summary_url

if summary_response.status_code != 200:
# Common non-200s:
Expand All @@ -7797,7 +7802,7 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_
"Startup summary is not available for this app. "
"This feature requires a platform version that has "
"not rolled out to your app's region yet."
)
), startup_summary_url

try:
body = summary_response.json()
Expand All @@ -7808,10 +7813,10 @@ def _fetch_startup_summaries(cmd, resource_group, name, slot, instance, runtime_
# Plaintext body (e.g. WEBSITE_DISABLE_CONTAINER_STARTUP_LOGS notice) —
# applies app-wide, hoist to instance-level SummaryFetchStatus later.
text = (summary_response.text or '').strip()
return startup_by_machine, (text or None)
return startup_by_machine, (text or None), startup_summary_url

startup_by_machine, notice = _parse_startup_summary_body(body, target_machine)
return startup_by_machine, notice
return startup_by_machine, notice, startup_summary_url


def _correlate_startups_with_runtime(runtime_items, startup_by_machine, app_wide_fetch_status):
Expand Down Expand Up @@ -7887,7 +7892,7 @@ def troubleshoot_status(cmd, resource_group, name, slot=None, instance=None, rep
cmd, subscription_id, resource_group, name, slot_segment,
arm_instance_id, api_version, instance, id_to_machine)

startup_by_machine, app_wide_fetch_status = _fetch_startup_summaries(
startup_by_machine, app_wide_fetch_status, startup_summary_url = _fetch_startup_summaries(
cmd, resource_group, name, slot, instance, runtime_items)

orphan_startups = _correlate_startups_with_runtime(
Expand All @@ -7899,8 +7904,9 @@ def troubleshoot_status(cmd, resource_group, name, slot=None, instance=None, rep
'instances': runtime_items,
}
if orphan_startups:
payload['orphanStartups'] = orphan_startups
payload['orphanStartups'] = orphan_startups[:_MAX_ORPHAN_STARTUPS]
if report:
payload['startupSummaryUrl'] = startup_summary_url
from azure.cli.command_modules.appservice import _troubleshoot_status_report
_troubleshoot_status_report.render_report(payload)
return None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
import unittest
from unittest import mock
import io
import os
import sys
import types
import unittest
from unittest import mock
from collections.abc import Mapping

from azure.core.exceptions import HttpResponseError
Expand Down Expand Up @@ -1840,6 +1841,30 @@ def test_troubleshoot_status_all_instances(self, requests_get_mock, send_raw_req
timeout=30,
)

@mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers',
return_value={'Authorization': 'Bearer token'})
@mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url',
return_value='https://myapp.scm.azurewebsites.net')
@mock.patch('azure.cli.command_modules.appservice.custom.send_raw_request')
@mock.patch('requests.get')
def test_troubleshoot_status_limits_orphan_startups_in_structured_output(
self, requests_get_mock, send_raw_request_mock, _scm_url_mock, _headers_mock):
send_raw_request_mock.side_effect = [
mock.MagicMock(json=mock.MagicMock(return_value=self._instances_payload({}))),
mock.MagicMock(json=mock.MagicMock(return_value=self._arm_response([]))),
]
requests_get_mock.return_value = self._make_response(200, json_data=[{
'InstanceId': 'previous-{}'.format(index),
'Startup': {'Succeeded': 0, 'Failed': 1},
} for index in range(6)])

result = troubleshoot_status(self.cmd, 'myRG', 'myApp')

self.assertEqual(len(result['orphanStartups']), 5)
self.assertEqual(
[entry['InstanceId'] for entry in result['orphanStartups']],
['previous-{}'.format(index) for index in range(5)])

@mock.patch('azure.cli.command_modules.appservice.custom.get_scm_site_headers',
return_value={'Authorization': 'Bearer token'})
@mock.patch('azure.cli.command_modules.appservice.custom._get_scm_url',
Expand Down Expand Up @@ -2078,6 +2103,64 @@ def test_troubleshoot_status_report_flag_renders_and_returns_none(
rendered_payload = render_mock.call_args.args[0]
self.assertEqual(rendered_payload['name'], 'myApp')
self.assertEqual(rendered_payload['instances'][0]['instanceId'], '7c2d9')
self.assertEqual(
rendered_payload['startupSummaryUrl'],
'https://myapp.scm.azurewebsites.net/api/startuplogs/summary')

def test_troubleshoot_status_report_supports_legacy_windows_encodings(self):
from azure.cli.command_modules.appservice import _troubleshoot_status_report as report_module

payload = {
'name': 'myApp',
'resourceGroup': 'myRG',
'instances': [{
'instanceId': '7c2d9',
'machineName': 'lw0sdlwk0007AB',
'state': 'Failed',
'lastError': 'ContainerTimeout',
'startup': {'Succeeded': 1, 'Failed': 1},
}],
'orphanStartups': [{
'InstanceId': 'orphan',
'Startup': {'Succeeded': 0, 'Failed': 1},
}],
}

for encoding in ('cp1252', 'cp437'):
with self.subTest(encoding=encoding):
output = io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors='strict')
with mock.patch.object(report_module.sys, 'stdout', output):
report_module.render_report(payload)
output.flush()

def test_troubleshoot_status_report_limits_orphan_startups(self):
from azure.cli.command_modules.appservice import _troubleshoot_status_report as report_module

payload = {
'name': 'myApp',
'startupSummaryUrl': 'https://myapp.scm.azurewebsites.net/api/startuplogs/summary',
'orphanStartups': [{
'InstanceId': 'orphan-{}'.format(index),
'Startup': {'Succeeded': 0, 'Failed': 1},
} for index in range(6)],
}
output = io.StringIO()

with mock.patch.object(report_module.sys, 'stdout', output):
report_module.render_report(payload)

rendered = output.getvalue()
for index in range(5):
self.assertIn('Instance orphan-{} Startup Summary'.format(index), rendered)
self.assertNotIn('Instance orphan-5 Startup Summary', rendered)
self.assertIn(
'Below startup records are from instances that no longer host the application '
'but occurred within the last 24 hours. Limited to 5 instances.',
rendered)
self.assertIn(
'To review the full 24-hour history, visit '
'https://myapp.scm.azurewebsites.net/api/startuplogs/summary.',
rendered)

def test_transform_troubleshoot_status_output_renders_error_columns(self):
# Regression: the LastError* columns exercise _format_dt only when
Expand Down Expand Up @@ -2999,6 +3082,34 @@ def test_report_uses_instance_id_when_machine_name_and_filter_are_missing(self):
self.assertIn('Instance:', printed_text)
self.assertIn('f5105a099b', printed_text)

def test_report_supports_legacy_windows_encodings(self):
from azure.cli.command_modules.appservice import _troubleshoot_config_report as report_module

payload = {
'name': 'myApp',
'resourceGroup': 'myRG',
'configCheck': {
'Settings': [{
'Setting': 'alwaysOn',
'Value': 'false',
'Details': 'Enable Always On.',
'DetailsLevel': 'warning',
}],
},
'runtimeError': {
'instanceId': 'f5105a099b0b07252d1991ca4444a69293f25b3fe8f8d948a2a5d46c82d33e9c5',
'state': 'Failed',
'lastError': 'ContainerTimeout',
},
}

for encoding in ('cp1252', 'cp437'):
with self.subTest(encoding=encoding):
output = io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors='strict')
with mock.patch.object(report_module.sys, 'stdout', output):
report_module.render_report(payload)
output.flush()

# ---- _extract_runtime_error ----

def test_extract_runtime_error_picks_latest_timestamp(self):
Expand Down