Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
217 changes: 206 additions & 11 deletions cli/azd/cmd/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package cmd
import (
"context"
"fmt"
"io"
"strings"

"github.com/azure/azure-dev/cli/azd/cmd/actions"
"github.com/azure/azure-dev/cli/azd/internal"
Expand All @@ -15,6 +17,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/azure"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
"github.com/azure/azure-dev/cli/azd/pkg/containerapps"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/infra"
Expand All @@ -28,6 +31,7 @@ type monitorFlags struct {
monitorLive bool
monitorLogs bool
monitorOverview bool
monitorTail bool
global *internal.GlobalCommandOptions
internal.EnvFlag
}
Expand All @@ -40,7 +44,15 @@ func (m *monitorFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommand
"Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.",
)
local.BoolVar(&m.monitorLogs, "logs", false, "Open a browser to Application Insights Logs.")
local.BoolVar(&m.monitorOverview, "overview", false, "Open a browser to Application Insights Overview Dashboard.")
local.BoolVar(
&m.monitorOverview, "overview", false, "Open a browser to Application Insights Overview Dashboard.",
)
local.BoolVar(
&m.monitorTail,
"tail",
false,
"Stream application logs from a deployed service directly to the terminal.",
)
m.EnvFlag.Bind(local, global)
m.global = global
}
Expand All @@ -65,6 +77,8 @@ type monitorAction struct {
subResolver account.SubscriptionTenantResolver
resourceManager infra.ResourceManager
resourceService *azapi.ResourceService
azureClient *azapi.AzureClient
containerAppService containerapps.ContainerAppService
console input.Console
flags *monitorFlags
portalUrlBase string
Expand All @@ -77,6 +91,8 @@ func newMonitorAction(
subResolver account.SubscriptionTenantResolver,
resourceManager infra.ResourceManager,
resourceService *azapi.ResourceService,
azureClient *azapi.AzureClient,
containerAppService containerapps.ContainerAppService,
console input.Console,
flags *monitorFlags,
cloud *cloud.Cloud,
Expand All @@ -87,6 +103,8 @@ func newMonitorAction(
env: env,
resourceManager: resourceManager,
resourceService: resourceService,
azureClient: azureClient,
containerAppService: containerAppService,
console: console,
flags: flags,
subResolver: subResolver,
Expand All @@ -96,7 +114,8 @@ func newMonitorAction(
}

func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error) {
if !m.flags.monitorLive && !m.flags.monitorLogs && !m.flags.monitorOverview {
if !m.flags.monitorLive && !m.flags.monitorLogs && !m.flags.monitorOverview &&
!m.flags.monitorTail {
m.flags.monitorOverview = true
}

Expand All @@ -107,13 +126,20 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error)
}
}

// Handle --tail: stream application logs directly to the terminal
if m.flags.monitorTail {
return m.runTail(ctx)
}

aspireDashboard := apphost.AspireDashboardUrl(ctx, m.env, m.alphaFeaturesManager)
if aspireDashboard != nil {
openWithDefaultBrowser(ctx, m.console, aspireDashboard.Link)
return nil, nil
}

resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment(ctx, m.env.GetSubscriptionId(), m.env.Name())
resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment(
ctx, m.env.GetSubscriptionId(), m.env.Name(),
)
if err != nil {
return nil, fmt.Errorf("discovering resource groups from deployment: %w", err)
}
Expand All @@ -140,14 +166,20 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error)

if len(insightsResources) == 0 && (m.flags.monitorLive || m.flags.monitorLogs) {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf("no Application Insights resource found: %w", internal.ErrResourceNotConfigured),
Err: fmt.Errorf(
"no Application Insights resource found: %w",
internal.ErrResourceNotConfigured,
),
Suggestion: "Ensure your infrastructure includes an Application Insights component.",
}
}

if len(portalResources) == 0 && m.flags.monitorOverview {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf("no Application Insights dashboard found: %w", internal.ErrResourceNotConfigured),
Err: fmt.Errorf(
"no Application Insights dashboard found: %w",
internal.ErrResourceNotConfigured,
),
Suggestion: "Ensure your infrastructure includes an Application Insights dashboard.",
}
}
Expand All @@ -160,25 +192,187 @@ func (m *monitorAction) Run(ctx context.Context) (*actions.ActionResult, error)
for _, insightsResource := range insightsResources {
if m.flags.monitorLive {
openWithDefaultBrowser(ctx, m.console,
fmt.Sprintf("%s/#@%s/resource%s/quickPulse", m.portalUrlBase, tenantId, insightsResource.Id))
fmt.Sprintf(
"%s/#@%s/resource%s/quickPulse",
m.portalUrlBase, tenantId, insightsResource.Id,
))
}

if m.flags.monitorLogs {
openWithDefaultBrowser(ctx, m.console,
fmt.Sprintf("%s/#@%s/resource%s/logs", m.portalUrlBase, tenantId, insightsResource.Id))
fmt.Sprintf(
"%s/#@%s/resource%s/logs",
m.portalUrlBase, tenantId, insightsResource.Id,
))
}
}

for _, portalResource := range portalResources {
if m.flags.monitorOverview {
openWithDefaultBrowser(ctx, m.console,
fmt.Sprintf("%s/#@%s/dashboard/arm%s", m.portalUrlBase, tenantId, portalResource.Id))
fmt.Sprintf(
"%s/#@%s/dashboard/arm%s",
m.portalUrlBase, tenantId, portalResource.Id,
))
}
}

return nil, nil
}

// streamableResource represents a deployed Azure resource that supports log streaming.
type streamableResource struct {
Name string
ResourceGroup string
Type azapi.AzureResourceType
}

// supportsLogStreaming reports whether the Azure resource type supports direct log streaming.
func supportsLogStreaming(resourceType string) bool {
switch azapi.AzureResourceType(resourceType) {
case azapi.AzureResourceTypeWebSite,
azapi.AzureResourceTypeContainerApp:
return true
}
return false
}

// runTail discovers deployed resources and streams application logs to the terminal.
func (m *monitorAction) runTail(ctx context.Context) (*actions.ActionResult, error) {
resourceGroups, err := m.resourceManager.GetResourceGroupsForEnvironment(
ctx, m.env.GetSubscriptionId(), m.env.Name(),
)
if err != nil {
return nil, fmt.Errorf("discovering resource groups from deployment: %w", err)
}

// Collect all resources that support log streaming
var streamable []streamableResource
for _, resourceGroup := range resourceGroups {
resources, err := m.resourceService.ListResourceGroupResources(
ctx,
azure.SubscriptionFromRID(resourceGroup.Id),
resourceGroup.Name,
nil,
)
if err != nil {
return nil, fmt.Errorf("listing resources: %w", err)
}

for _, resource := range resources {
if supportsLogStreaming(resource.Type) {
streamable = append(streamable, streamableResource{
Name: resource.Name,
ResourceGroup: resourceGroup.Name,
Type: azapi.AzureResourceType(resource.Type),
})
}
}
}

if len(streamable) == 0 {
return nil, &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"no services that support log streaming found: %w",
internal.ErrResourceNotConfigured,
),
Suggestion: "Ensure your project includes App Service, Azure Functions, or " +
"Container App resources.",
}
}

// If there are multiple streamable resources, prompt the user to select one
selected := streamable[0]
if len(streamable) > 1 {
choices := make([]string, len(streamable))
for i, r := range streamable {
displayType := azapi.GetResourceTypeDisplayName(r.Type)
if displayType == "" {
displayType = string(r.Type)
}
choices[i] = fmt.Sprintf("%s (%s)", r.Name, displayType)
}

idx, err := m.console.Select(ctx, input.ConsoleOptions{
Message: "Select a service to stream logs from:",
Options: choices,
})
if err != nil {
return nil, fmt.Errorf("selecting service: %w", err)
}
selected = streamable[idx]
}

displayType := azapi.GetResourceTypeDisplayName(selected.Type)
if displayType == "" {
displayType = string(selected.Type)
}

m.console.Message(ctx, fmt.Sprintf(
"Streaming logs from %s (%s). Press Ctrl+C to stop.\n",
output.WithHighLightFormat(selected.Name),
displayType,
))

logStream, err := m.getLogStream(ctx, selected)
if err != nil {
return nil, fmt.Errorf("starting log stream for %s: %w", selected.Name, err)
}
defer logStream.Close()

// Stream log data to the console output writer
writer := m.console.GetWriter()
if _, err := io.Copy(writer, logStream); err != nil {
// Context cancellation (Ctrl+C) is expected when streaming
if ctx.Err() != nil {
return nil, nil
}
// Check for connection close/reset errors during streaming, which are
// normal when the user stops streaming or the server closes the connection
if isStreamClosedError(err) {
return nil, nil
}
return nil, fmt.Errorf("streaming logs: %w", err)
}

return nil, nil
}

// isStreamClosedError reports whether the error indicates the log stream
// connection was closed, which is expected during normal termination.
func isStreamClosedError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "use of closed network connection") ||
strings.Contains(msg, "EOF")
}

// getLogStream returns a streaming reader for the given resource's application logs.
func (m *monitorAction) getLogStream(
ctx context.Context,
resource streamableResource,
) (io.ReadCloser, error) {
subscriptionId := m.env.GetSubscriptionId()

switch resource.Type {
case azapi.AzureResourceTypeWebSite:
return m.azureClient.GetAppServiceLogStream(
ctx, subscriptionId, resource.ResourceGroup, resource.Name,
)
case azapi.AzureResourceTypeContainerApp:
return m.containerAppService.GetLogStream(
ctx, subscriptionId, resource.ResourceGroup, resource.Name,
)
default:
return nil, fmt.Errorf(
"log streaming is not supported for resource type %s", resource.Type,
)
}
}

func getCmdMonitorHelpDescription(*cobra.Command) string {
return generateCmdHelpDescription(
fmt.Sprintf("Monitor a deployed application %s. For more information, go to: %s.",
Expand All @@ -188,8 +382,9 @@ func getCmdMonitorHelpDescription(*cobra.Command) string {

func getCmdMonitorHelpFooter(c *cobra.Command) string {
return generateCmdHelpSamplesBlock(map[string]string{
"Open Application Insights Overview Dashboard.": output.WithHighLightFormat("azd monitor --overview"),
"Open Application Insights Live Metrics.": output.WithHighLightFormat("azd monitor --live"),
"Open Application Insights Logs.": output.WithHighLightFormat("azd monitor --logs"),
"Open Application Insights Overview Dashboard.": output.WithHighLightFormat("azd monitor --overview"),
"Open Application Insights Live Metrics.": output.WithHighLightFormat("azd monitor --live"),
"Open Application Insights Logs.": output.WithHighLightFormat("azd monitor --logs"),
"Stream application logs directly to the terminal": output.WithHighLightFormat("azd monitor --tail"),
})
}
4 changes: 4 additions & 0 deletions cli/azd/cmd/testdata/TestFigSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2466,6 +2466,10 @@ const completionSpec: Fig.Spec = {
name: ['--overview'],
description: 'Open a browser to Application Insights Overview Dashboard.',
},
{
name: ['--tail'],
description: 'Stream application logs from a deployed service directly to the terminal.',
},
],
},
{
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/cmd/testdata/TestUsage-azd-monitor.snap
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Flags
--live : Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.
--logs : Open a browser to Application Insights Logs.
--overview : Open a browser to Application Insights Overview Dashboard.
--tail : Stream application logs from a deployed service directly to the terminal.

Global Flags
-C, --cwd string : Sets the current working directory.
Expand All @@ -27,4 +28,7 @@ Examples
Open Application Insights Overview Dashboard.
azd monitor --overview

Stream application logs directly to the terminal
azd monitor --tail


24 changes: 11 additions & 13 deletions cli/azd/extensions/microsoft.azd.concurx/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ require (
)

require (
dario.cat/mergo v1.0.2 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
Expand Down Expand Up @@ -83,20 +81,20 @@ require (
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading