Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6ed2900
first push
aamos-company Jul 7, 2026
40d2042
output updates, nest config check
aamos-company Jul 8, 2026
b27ee47
troubleshoot config: friendly retry message on SCM failure in --repor…
aamos-company Jul 10, 2026
ee5dc83
troubleshoot config: shorter failure message
aamos-company Jul 10, 2026
27e1b3b
troubleshoot config: drop trailing blank line when runtime section no…
aamos-company Jul 10, 2026
e8f3277
troubleshoot config: report polish
aamos-company Jul 10, 2026
176158e
troubleshoot config: retries, DetailsLevel, wrapping, hints
aamos-company Jul 10, 2026
cb40b5d
troubleshoot config: drop legacy IssueDetected fallback
aamos-company Jul 10, 2026
b3ab3ea
troubleshoot config: gate runtime section on timestamp only
aamos-company Jul 10, 2026
c20a2cd
troubleshoot config: print 'Feature is currently unavailable.' on SCM…
aamos-company Jul 10, 2026
7433270
troubleshoot config: address PR review
aamos-company Jul 13, 2026
d5c3ed1
troubleshoot config: extract --report rendering into helper module
aamos-company Jul 13, 2026
0e2d65d
troubleshoot config: show Instance + failing count in runtime error s…
aamos-company Jul 13, 2026
2118219
troubleshoot config: document the 15-minute runtime error freshness g…
aamos-company Jul 13, 2026
61d00a7
troubleshoot config: strip internal plumbing fields from JSON payload
aamos-company Jul 13, 2026
9473918
troubleshoot config: rename SCM 404 message to name the specific feature
aamos-company Jul 13, 2026
ba91678
troubleshoot config: address PR #33709 review feedback
aamos-company Jul 14, 2026
1a5e4f0
Update to include instance param
aamos-company Sep 3, 2026
d659926
Update code to pass linting
aamos-company Sep 8, 2026
da98491
make discoverable
aamos-company Sep 8, 2026
e598e2e
Merge upstream/dev into configtroubleshootcommand
aamos-company Sep 9, 2026
fcd6e39
Fix troubleshoot config report hints
aamos-company Sep 9, 2026
06f2867
Fix troubleshoot config runtime error filtering
aamos-company Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -2494,6 +2494,41 @@
text: az webapp log startup show --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002
"""

helps['webapp troubleshoot config'] = """
type: command
short-summary: Validate configuration for a Linux web app and surface the last runtime error.
long-summary: >
Aggregates two data sources into a single report:

(1) Built-in configuration checks — a set of common
Linux App Service settings (linuxFxVersion, port binding, startup
command, alwaysOn, health check path, ...) evaluated against the
running site's configuration snapshot.

(2) The site runtime status error reported by App Service for the worker
represented by the configuration-check snapshot.
Use `--instance` with a worker machine name to retrieve that worker's
configuration checks. The instance ID returned by those checks is then
used to select the matching runtime error.
The runtime error recommendation section is only surfaced when the
error occurred within the last 15 minutes; older errors are still
included in the structured payload but are hidden from the `--report`
view.

By default the command returns a structured payload so the standard
`-o json/yaml/table` formatters handle output. Pass `--report` to
print a human-readable two-section report to stdout instead.
examples:
- name: Run the built-in configuration checks and show the runtime error, if any (JSON by default)
text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup
- name: Print the human-readable report
text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --report
- name: Target a deployment slot
text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --slot staging
- name: Run checks and show the runtime error for a specific worker instance
text: az webapp troubleshoot config --name MyWebApp --resource-group MyResourceGroup --instance lw0sdlwk000002
"""

helps['webapp troubleshoot'] = """
type: group
short-summary: Diagnose common Linux web app problems.
Expand Down
10 changes: 10 additions & 0 deletions src/azure-cli/azure/cli/command_modules/appservice/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,16 @@ def load_arguments(self, _):
with self.argument_context('webapp log startup show') as c:
c.argument('filename', options_list=['--filename', '-f'], help='Name of a specific startup log file to display. If not specified, shows the latest log (preferring failures).')

with self.argument_context('webapp troubleshoot config') as c:
c.argument('name', arg_type=webapp_name_arg_type, id_part=None)
c.argument('resource_group_name', arg_type=resource_group_name_type)
c.argument('slot', options_list=['--slot', '-s'], help="the name of the slot. Defaults to the production slot if not specified")
c.argument('instance', options_list=['--instance'],
help='Filter configuration checks by worker machine name. The runtime error recommendation '
'uses the corresponding instance ID returned by the configuration check.')
c.argument('report', options_list=['--report'], arg_type=get_three_state_flag(),
help='Print a human-readable report instead of the structured payload.')

with self.argument_context('webapp troubleshoot status') as c:
c.argument('name', arg_type=webapp_name_arg_type, id_part=None)
c.argument('resource_group', arg_type=resource_group_name_type)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

"""Human-readable report rendering for 'az webapp troubleshoot config --report'.

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.

Examples:
Note: there is a PR out that will improve the suggestions for the last error details

  • Built in checks is unavailable but runtime status still shows (if the error time is within the last 15 mins)
Image
  • Built in checks available (**test data, not actual value. Corresponding Json from Kudu below). If any of the details level is error, hints will be shown
Image Image
  • Built in checks + site runtime recommendation
Image


Extracted from ``custom.py`` to keep the command's control flow separate from
its presentation layer. The command builds a structured payload; this module
renders it. ``render_report(payload)`` is the sole public entry point.
"""

import shutil
import sys
import textwrap
from datetime import datetime, timezone

from azure.cli.core.style import Style, print_styled_text


def _format_dt(value):
"""Human-readable timestamp: 'YYYY-MM-DD HH:MM:SS UTC' (or best-effort)."""
if not value:
return None
if isinstance(value, str):
v = value.replace('T', ' ')
is_utc = v.endswith('Z')
if '.' in v:
v = v.split('.', 1)[0]
if is_utc:
if v.endswith('Z'):
v = v[:-1]
v = v + ' UTC'
elif v.endswith('+00:00'):
v = v[:-6] + ' UTC'
elif '+' in v:
v = v.split('+', 1)[0]
return v
return str(value)


def _short_id(instance_id):
"""Truncate a long hex ARM instanceId to 10 characters for display."""
if not instance_id:
return None
if len(instance_id) > 12:
return instance_id[:10]
return instance_id


def _relative_age(iso_value):
"""Return a short 'Nh Mm ago' / 'Nm ago' / 'just now' / 'in the future' string
for an ISO-8601 UTC timestamp, or None if the input is unparseable/missing."""
if not iso_value or not isinstance(iso_value, str):
return None
v = iso_value
if '.' in v:
head, _, tail = v.partition('.')
tz = ''
for suffix in ('Z', '+', '-'):
if suffix in tail:
idx = tail.find(suffix)
tz = tail[idx:]
break
v = head + tz
v = v.replace('Z', '+00:00')
try:
dt = datetime.fromisoformat(v)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
total_seconds = int((datetime.now(timezone.utc) - dt).total_seconds())
if total_seconds < 0:
age = 'in the future'
elif total_seconds < 60:
age = 'just now'
else:
minutes = total_seconds // 60
if minutes < 60:
age = '{}m ago'.format(minutes)
else:
hours, rem_min = divmod(minutes, 60)
if hours < 24:
age = '{}h {}m ago'.format(hours, rem_min) if rem_min else '{}h ago'.format(hours)
else:
days, rem_hours = divmod(hours, 24)
age = '{}d {}h ago'.format(days, rem_hours) if rem_hours else '{}d ago'.format(days)
return age


def _out(*objs):
print_styled_text(*objs, file=sys.stdout)


def _row(*objs):
_out(list(objs))


def _labeled(label, value, style=Style.PRIMARY):
"""Emit a labeled value with wrapped lines aligned below the value."""
text = '' if value is None else str(value)
term_w = shutil.get_terminal_size(fallback=(120, 40)).columns
indent = ' ' * len(label)
lines = textwrap.wrap(text, width=max(20, term_w - len(label))) or [text]
_row((style, label), (style, lines[0]))
for continuation in lines[1:]:
_row((style, indent), (style, continuation))


def _details_level(setting):
raw = setting.get('DetailsLevel')
if raw is None:
raw = setting.get('detailsLevel')
level = raw.strip().lower() if isinstance(raw, str) else ''
return level if level in ('info', 'warning', 'error') else 'info'


def _style_for_level(level):
if level == 'error':
return Style.ERROR
if level == 'warning':
return Style.WARNING
return Style.SUCCESS


def _get_settings(config_check):
settings = config_check.get('Settings') or config_check.get('settings') or []
if not isinstance(settings, list):
return []
return [setting for setting in settings if isinstance(setting, dict)]


def _render_snapshot_metadata(payload, config_check):
machine_name = config_check.get('MachineName') or config_check.get('machineName')
requested_machine_name = payload.get('requestedMachineName')
instance_id = config_check.get('InstanceId') or config_check.get('instanceId')
written_at_raw = config_check.get('WrittenAt') or config_check.get('writtenAt')
if isinstance(machine_name, str):
machine_name = machine_name.strip()
if isinstance(requested_machine_name, str):
requested_machine_name = requested_machine_name.strip()
if isinstance(written_at_raw, str):
written_at_raw = written_at_raw.strip()

instance_value = machine_name or requested_machine_name or _short_id(instance_id)
if instance_value:
_labeled('Instance: ', instance_value, Style.HIGHLIGHT)
if written_at_raw:
_labeled('Last Updated: ', _format_dt(written_at_raw) or str(written_at_raw), Style.HIGHLIGHT)


def _render_settings_table(settings):
term_w = shutil.get_terminal_size(fallback=(120, 40)).columns
setting_w = max(20, min(40, max(len(str(s.get('Setting') or '')) for s in settings) + 2))
value_w = max(15, min(30, max(len(str(s.get('Value') or '')) for s in settings) + 2))
header = '{sname:<{sw}}{vname:<{vw}}{dname}'.format(
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)))

for setting in settings:
name = str(setting.get('Setting') or '')
value = str(setting.get('Value') if setting.get('Value') is not None else '')
details = str(setting.get('Details') or '')
prefix = '{s:<{sw}}{v:<{vw}}'.format(s=name, sw=setting_w, v=value, vw=value_w)
lines = textwrap.wrap(details, width=max(20, term_w - len(prefix))) or [details]
details_style = _style_for_level(_details_level(setting))
_row((Style.PRIMARY, prefix), (details_style, lines[0]))
for continuation in lines[1:]:
_row((Style.PRIMARY, ' ' * len(prefix)), (details_style, continuation))


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))
_out()
if payload.get('configCheck') is None:
if payload.get('configCheckStatus') == 404:
message = payload.get('configCheckMessage') or (
'Configuration check feature is currently disabled. Please try again later.')
_row((Style.WARNING, message))
else:
_row((Style.WARNING,
'Failed to retrieve built-in configuration checks. Please try again. '
'If the issue persists, restart the application (\'az webapp restart\') and confirm the SCM (Kudu) '
'is running and reachable.'))
return

if not settings:
_row((Style.WARNING, 'No built-in configuration checks reported.'))
else:
_render_settings_table(settings)


def _render_runtime_error(runtime_error):
_out()
_out()
_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 '')
age = _relative_age(timestamp_raw) if timestamp else None
if age:
timestamp = '{} ({})'.format(timestamp, age)

fields = [
('Instance ', _short_id(runtime_error.get('instanceId'))),
('State ', runtime_error.get('state')),
('Last Error ', runtime_error.get('lastError')),
('Last Error Details ', runtime_error.get('lastErrorDetails')),
('Last Error Timestamp ', timestamp),
]
for label, value in fields:
if value:
_labeled(label, value)
_out()


def _render_hints(payload, any_issue):
resource_group = payload.get('resourceGroup') or '<resource-group>'
site_name = payload.get('name') or '<site-name>'
slot = payload.get('slot')
slot_arg = ' --slot {}'.format(slot) if slot else ''
_out()
_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))
_out(' Review config options: az webapp config set -n {} -g {}{} '
'--help'.format(site_name, resource_group, slot_arg))
_out(' Check application logs: az webapp log tail -n {} -g {}{}'.format(
site_name, resource_group, slot_arg))


def render_report(payload):
"""Print built-in checks, a recent runtime recommendation, and hints."""
config_check = payload.get('configCheck') or {}
settings = _get_settings(config_check)
runtime_error = payload.get('runtimeError')
show_runtime = bool(runtime_error and runtime_error.get('isRecent'))
any_issue = any(_details_level(setting) in ('warning', 'error') for setting in settings)

_render_config_checks(payload, config_check, settings)
if show_runtime:
_render_runtime_error(runtime_error)
if any_issue or show_runtime:
_render_hints(payload, any_issue)
31 changes: 30 additions & 1 deletion src/azure-cli/azure/cli/command_modules/appservice/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ def transform_runtime_list_output(result):
]) for r in result]


def transform_troubleshoot_config_output(result):

@aamos-company aamos-company Jul 14, 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.

The table output will only show the config checks:
image

JSON
image

"""Flatten the troubleshoot config payload into a per-setting table.

Reads Settings out of the nested ``configCheck`` field (the verbatim SCM
body). Falls back gracefully for non-dict / empty payloads (e.g. --report
was passed and the command returned ``None``).
"""
from collections import OrderedDict
if not isinstance(result, dict):
return []
config_check = result.get('configCheck') or {}
settings = config_check.get('Settings') or config_check.get('settings') or []
if not isinstance(settings, list):
return []
written_at = config_check.get('WrittenAt') or config_check.get('writtenAt')
if isinstance(written_at, str):
written_at = written_at.strip()
details_header = (
'Details (Last Updated: {})'.format(written_at)
if written_at else 'Details'
)
return [OrderedDict([
('Setting', s.get('Setting') or s.get('setting') or ''),
('Value', s.get('Value') if s.get('Value') is not None else s.get('value') or ''),
(details_header, s.get('Details') or s.get('details') or ''),
]) for s in settings if isinstance(s, dict)]


def transform_troubleshoot_status_output(result):
"""Flatten the nested `instances` payload into one row per worker for `-o table`.
Column layout: InstanceId / State / Details / (LastError /
Expand Down Expand Up @@ -127,7 +155,6 @@ def _print_hint(app=app_name, rg=resource_group):
.format(name=app, rg=rg))

atexit.register(_print_hint)

return rows


Expand Down Expand Up @@ -349,6 +376,8 @@ def load_command_table(self, _):
g.custom_show_command('show', 'show_startup_log')

with self.command_group('webapp troubleshoot', is_preview=True) as g:
g.custom_command('config', 'troubleshoot_config',
table_transformer=transform_troubleshoot_config_output)
g.custom_command('status', 'troubleshoot_status',
table_transformer=transform_troubleshoot_status_output)

Expand Down
Loading