Skip to content
Open
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
4 changes: 4 additions & 0 deletions cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import (
func TestTelemetryEventConstants(t *testing.T) {
t.Parallel()
require.Equal(t, "ext.update", events.ExtensionUpdateEvent)
// Agent operation classifications use values of the existing extension.event
// field on ext.usage; they must not require another host span or attribute.
require.Equal(t, "ext.usage", events.ExtensionUsageEvent)
require.Equal(t, "extension.event", string(fields.ExtensionEvent.Key))
}

// TestTelemetryFieldConstants verifies that all telemetry field constants added for
Expand Down
9 changes: 9 additions & 0 deletions cli/azd/extensions/azure.ai.agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ the OpenTelemetry operation ID. A project with multiple agent classifications
reports one row for each classification. The event never includes agent names,
service keys, paths, URLs, prompts, or other customer content.

### Operation classification markers

Init, provision and deploy also emit bounded
`agent.operation.v1.<operation>.<category>.<telephony>` values in the existing
`extension.event` field of `ext.usage`, with no additional attributes. Existing
`agent.context.resolved` and command results are unchanged. See
[operation statistics](docs/operation-telemetry.md) for the vocabulary, query and
coverage limits. Marker success must not be used as command success.

## Non-interactive automation

See the shared [AI extension non-interactive input reference](../ai-non-interactive.md)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Synthetic query validation: no customer telemetry or ingestion changes.
// Expected: observed=4, successes=2, failures=2, missingClassification=1, cancellations=1.
// The mixed operation has two marker rows but remains ONE completed command.
let Completed = datatable(operation_Id:string, id:string, operation:string, success:bool, resultCode:string)
[
"one", "1", "init", true, "Success",
"two", "2", "init", false, "ext.user.cancelled",
"three", "3", "deploy", true, "Success",
"four", "4", "provision", false, "internal.error"
];
let RawMarkers = datatable(operation_Id:string, eventName:string)
[
"one", "agent.operation.v1.init.voice_managed.none",
"three", "agent.operation.v1.deploy.hosted_invocations_ws.none",
"three", "agent.operation.v1.deploy.voice_hosted_wrapper.none",
"three", "agent.operation.v1.deploy.voice_hosted_wrapper.none",
"four", "agent.operation.v1.provision.voice_byom.enabled"
];
let Markers = RawMarkers
| parse eventName with "agent.operation.v1." operation "." category "." telephony
| summarize categories=make_set(category) by operation_Id, operation;
Completed
| join kind=leftouter Markers on operation_Id, operation
| summarize observed=count(), successes=countif(success), failures=countif(not(success)),
missingClassification=countif(isnull(categories)),
cancellations=countif(resultCode startswith "user.canceled"
or resultCode in ("ext.user.cancelled", "internal.operation_cancelled", "internal.operation_aborted"))
| extend passed = observed == 4 and successes == 2 and failures == 2
and missingClassification == 1 and cancellations == 1
137 changes: 137 additions & 0 deletions cli/azd/extensions/azure.ai.agents/docs/operation-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Agent operation statistics

<!-- cspell:ignore tostring leftouter isnull strcat tobool countif todouble -->

Reuse existing command completion records for totals, successes and failures.
The only additional data is bounded text in the existing `extension.event` field
of `ext.usage`: `agent.operation.v1.<operation>.<category>.<telephony>`.
There are no new attributes, core spans, result codes, exporters or pipelines.
Existing `agent.context.resolved` fields and deduplication are unchanged.

| Segment | Fixed values |
|---|---|
| operation | `init`, `provision`, `deploy` |
| category | `hosted`, `hosted_invocations_ws`, `prompt`, `workflow`, `voice_managed`, `voice_byom`, `voice_hosted_wrapper`, `unknown` |
| telephony | `none`, `enabled`, `unknown` |

Voice aliases normalize to the same category. BYOM is the configured model mode,
not a guess from a customer model/deployment name. `invocations_ws` denotes a
transport, not proof of audio usage. Telephony means configured bindings, not
successful binding creation or phone calls. No names, paths, model names,
identifiers, credentials, prompts or configuration payloads are emitted.

## Coverage and behavior

- Extension init collects explicit kind intent, refining it at existing selection,
definition/adoption and reuse points. RunE return attempts to report the last
classification on success **or failure**. Unknown intent is not inferred from
later success. Failure before RunE, process termination or unavailable telemetry
can have no marker. Success/failure remain owned by existing command telemetry.
- Agent preprovision/predeploy handlers report in-memory service classifications
before their existing work. Failures before the hooks (such as package or config
errors) may lack markers. Skipped services that never enter the hook are absent.
- No additional project/file/Azure queries are made for classification. Unresolved
root `$ref` or an external definition override is unknown; no speculative reads.
- Distinct operation/category/telephony tuples are attempted once per reporter
process. Provision cannot suppress deploy, and init refinements do not double
count earlier intent. The original reporter's behavior is unchanged.
- Uses the existing best-effort reporter, no retries, one-second total deadline
per batch. Init preserves trace metadata with a bounded uncancelled reporting
context on return; telemetry failures never replace command errors/return values.
There is bounded latency, not zero overhead. No global background worker is added.
- **Core `azd init` is not the extension command.** Its totals/results already
exist, but its agent type remains unknown in this extension-only change. Reuse
inside `azd ai agent init` can be classified from its existing project read.

## Counting contract

Count completed command spans, **not marker rows or agents**. For mixed projects,
keep a sorted category combination and one command count; never assign one
project failure as each service's individual outcome. A hosted voice project may
contain both `hosted_invocations_ws` and `voice_hosted_wrapper`.

The left join below preserves failures without markers. Unclassified core commands
include both non-agent projects and early-failed agent projects: they cannot be
claimed as agent-only usage. Same-operation commands in a shared trace are marked
ambiguous instead of guessing which service/marker belongs to which command.
Do not add parent `up` and child operations together into one total.

Telemetry opt-out, nonofficial ZIP/dev installs, old hosts, the shared 100-event
invocation budget, crashes and ingestion delays can lose markers/completions.
The additional vocabulary is bounded to 72 possible tuples across three operations.
These are counts of **observed completions**, not an absolute census of users.
Report unknown/ambiguous coverage alongside classified rates, and use a fixed
host/extension release cohort. Installation alone does not prove agent involvement.

## KQL — existing Application Insights requests shape

No shared ingestion function needs modification. Validate this query against the
authorized destination and adapt table/column aliases for cooked Kusto/LENS data.
It has not been run against production customer data in this change.
The [synthetic fixture](operation-telemetry-fixture.kql) can be run without customer
tables: it expects four completions, two successes, two failures, one cancellation
and one unclassified failure, even though one mixed deploy has duplicate/multiple marker rows. This is
a query-engine acceptance fixture, not a claim of local Kusto execution.

```kusto
let since = ago(7d);
let Markers = requests
| where timestamp >= since - 1d
| where name == "ext.usage"
| where tostring(customDimensions["extension.id"]) == "azure.ai.agents"
| extend marker = tostring(customDimensions["extension.event"])
| parse marker with "agent.operation.v1." operation "." category "." telephony
| where operation in ("init", "provision", "deploy")
| where category in ("hosted", "hosted_invocations_ws", "prompt", "workflow",
"voice_managed", "voice_byom", "voice_hosted_wrapper", "unknown")
| where telephony in ("none", "enabled", "unknown")
| summarize categories=make_set(category), phones=make_set(telephony)
by operation_Id, operation;
let Completed = requests
| where timestamp >= since
| extend command = tostring(customDimensions["cmd.entry"])
| where (name == "ext.run" and command == "cmd.ai.agent.init")
or name in ("cmd.init", "cmd.provision", "cmd.deploy")
| summarize arg_max(timestamp, *) by operation_Id, id
| extend operation = case(name == "ext.run", "init", name == "cmd.init", "init",
name == "cmd.provision", "provision", "deploy")
| extend scope = iff(name == "ext.run", "agent_extension", "core");
let Cardinality = Completed
| summarize commandSpans=count() by operation_Id, operation, scope;
Completed
| join kind=leftouter Cardinality on operation_Id, operation, scope
| join kind=leftouter Markers on operation_Id, operation
| extend attribution = case(name == "cmd.init", "core_init_unclassified",
commandSpans != 1, "ambiguous_trace",
isnull(categories), "no_marker", "classified")
| extend agentTypes = iff(attribution == "classified",
strcat_array(array_sort_asc(categories), "+"), "unknown")
| extend phoneConfiguration = iff(attribution == "classified",
strcat_array(array_sort_asc(phones), "+"), "unknown")
| extend succeeded = tobool(success), code = tostring(resultCode)
| summarize total=count(), successes=countif(succeeded == true),
failures=countif(succeeded == false), missingResult=countif(isnull(succeeded)),
cancellations=countif(code startswith "user.canceled"
or code in ("ext.user.cancelled", "internal.operation_cancelled", "internal.operation_aborted"))
by scope, operation, attribution, agentTypes, phoneConfiguration
| extend successRate = iff(successes + failures > 0, todouble(successes)/(successes+failures), real(null)),
failureRate = iff(successes + failures > 0, todouble(failures)/(successes+failures), real(null))
```

`total = successes + failures + missingResult`. Cancellations are a diagnostic
subset, not an extra addition to total. The host's existing success/resultCode
semantics are preserved (a graceful nil-error cancellation can remain success).
`ext.usage.success` is never used to determine business outcome. A marker without
a completion is not proof of failure. Unknown types before classification remain
unknown; these rates must not be advertised as complete per-type population rates.

## Validation and rollout

Fake-host tests check actual submitted event names, nil attribute maps,
per-operation deduplication, concurrent calls, type privacy and unchanged init
failure behavior. Existing telemetry tests continue to assert the original contract.
Test official-registry ingestion and trace joins before using a production KPI;
local ZIP sources remain deliberately rejected by host admission. These additional
event values and their product metadata need normal extension telemetry review;
using existing fields does not bypass privacy rules. No customer telemetry was
accessed or uploaded by the unit tests.
9 changes: 8 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
--image registry.example.com/agents/my-agent:v1 --registry-connection production-registry`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Record bounded intent before validation so failures are not a success-only sample.
ctx := withInitOperationContext(azdext.WithAccessToken(cmd.Context()), flags.kind,
flags.manifestPointer != "" || len(args) > 0)
defer reportInitOperation(ctx)
flags.noPrompt = extCtx.NoPrompt
if flags.env == "" {
flags.env = extCtx.Environment
Expand Down Expand Up @@ -1361,7 +1365,6 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
}
}

ctx := azdext.WithAccessToken(cmd.Context())
azdClient, err := azdext.NewAzdClient()
if err != nil {
return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err))
Expand Down Expand Up @@ -1522,6 +1525,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
// and code scaffolding. The image is wired into azure.yaml and ACR is
// skipped by the existing --image handling in InitAction.Run.
if flags.image != "" && flags.manifestPointer == "" {
recordInitProperties(ctx, map[string]any{"kind": "hosted"})
// Validate early so we fail before initializing a project/template.
if err := validateImageFlag(flags.image, flags.deployMode); err != nil {
return err
Expand Down Expand Up @@ -1676,6 +1680,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
useExisting = *confirmResp.Value
}
if useExisting {
recordInitProject(ctx, detection.project)
if err := runReuseProjectAgentServices(
ctx, flags, azdClient, detection.services,
); err != nil {
Expand Down Expand Up @@ -1974,6 +1979,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
}

case initModeVoice:
recordInitProperties(ctx, map[string]any{"kind": "voice", "modelType": "managed"})
// User chose to create a declarative (managed) voice agent.
// Resolve the agent name, synthesize a prompt-voice manifest,
// and route it through the manifest flow — the same path as
Expand Down Expand Up @@ -2324,6 +2330,7 @@ func (a *InitAction) Run(ctx context.Context) error {
if err != nil {
return fmt.Errorf("downloading agent.yaml: %w", err)
}
recordInitDefinition(ctx, agentManifest.Template)
// Prompt for deploy mode (code vs container) for hosted agents.
// Code deploy is supported for Python and .NET projects.
if hostedAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,7 @@ func runInitFromAzureYaml(
content []byte,
) error {
projectName := foundryProjectName(content)
recordInitProjectContent(ctx, content)
agentNameOverride, err := adoptedAgentNameOverride(flags)
if err != nil {
return err
Expand Down Expand Up @@ -1022,6 +1023,7 @@ func runInitFromAzureYaml(
return err
}
promptOnly := stagedInfo.promptOnly()
recordInitProjectContent(ctx, stagedContent)
if agentNameOverride != "" {
// Validate against the fully staged template so services whose host lives
// inside a local $ref are counted the same way azd-core will load them.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type InitFromCodeAction struct {
}

func (a *InitFromCodeAction) Run(ctx context.Context) error {
recordInitProperties(ctx, map[string]any{"kind": "hosted"})
var err error
a.projectConfig, err = a.ensureProject(ctx)
if err != nil {
Expand Down Expand Up @@ -93,6 +94,7 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error {
}

if localDefinition != nil {
recordInitDefinition(ctx, localDefinition)

// Generate .agentignore. The agent definition is written into the
// azure.yaml service entry below, not to an on-disk agent.yaml.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func runReuseDefinition(
fmt.Sprintf("Fix %s and retry, or remove the file to start a fresh init.", displayPath),
)
}
recordInitDefinition(ctx, def)

fmt.Println(color.HiBlackString(
"Detected existing agent definition: %s (name: %s).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func runInitManaged(
harness string,
manifest *promptAgentManifest,
) error {
recordInitProperties(ctx, map[string]any{"kind": "prompt"})
// Every prompt-agent init converges here — interactive picker, --kind prompt,
// and manifest adoption alike — so this is the one place the preview notice
// reaches all of them. Emitted before validation so it is seen even when the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type projectAgentService struct {
type projectAgentDetection struct {
services []projectAgentService
projectRoot string
project *azdext.ProjectConfig
}

// detectProjectAgentServices returns the agent services the azd host reports for
Expand Down Expand Up @@ -67,6 +68,7 @@ func detectProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient
}

return projectAgentDetection{
project: project,
services: services,
projectRoot: project.GetPath(),
}
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import (
"azureaiagent/internal/pkg/agents/optimize_api"
"azureaiagent/internal/pkg/envkey"
"azureaiagent/internal/project"
agentTelemetry "azureaiagent/internal/telemetry"

"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
foundryTelemetry "github.com/azure/azure-dev/cli/azd/pkg/foundry/telemetry"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
Expand All @@ -38,6 +40,7 @@ func configureExtensionHost(host *azdext.ExtensionHost) {

func configureExtensionHostWithTelemetry(host *azdext.ExtensionHost, telemetryReporter *agentContextReporter) {
azdClient := host.Client()
operationReporter := newOperationReporter()

// IMPORTANT: service target name here must match the name used in the extension manifest.
host.
Expand All @@ -46,13 +49,21 @@ func configureExtensionHostWithTelemetry(host *azdext.ExtensionHost, telemetryRe
}).
WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error {
telemetryReporter.reportProjectConfig(ctx, azdClient.Telemetry(), args.Project, "provision")
usage := foundryTelemetry.NewReporter(azdClient.Telemetry(), nil)
if classes := operationProjectClasses(args.Project); len(classes) > 0 {
operationReporter.report(ctx, usage, "provision", classes)
}
return preprovisionHandler(ctx, azdClient, args)
}).
WithProjectEventHandler("postprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error {
return postprovisionHandler(ctx, azdClient, args)
}).
WithServiceEventHandler("predeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error {
telemetryReporter.reportService(ctx, azdClient.Telemetry(), args.Project, args.Service, "deploy")
usage := foundryTelemetry.NewReporter(azdClient.Telemetry(), nil)
operationReporter.report(ctx, usage, "deploy", []agentTelemetry.OperationClass{
operationServiceClass(args.Service),
})
return predeployHandler(ctx, azdClient, args)
}, &azdext.ServiceEventOptions{Host: AiAgentHost}).
WithServiceEventHandler("postdeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error {
Expand Down
Loading
Loading