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
110 changes: 109 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,119 @@ This repository contains sample agents and prompts for building with the Microso

## SDK Versions

### Observability routing

Agent 365 OBS export always uses `/observabilityService`, including autonomous,
AI Teammate, and on-behalf-of (OBO) conversations. `/observability` is not a
fallback for missing tokens, authentication failures, or failed exports.
This changes telemetry transport only: preserve the agent identity, user baggage,
and the existing MCP/Graph authentication flows.

The samples explicitly select S2S using the API supported by their dependencies:

| Sample SDK | Required configuration |
|---|---|
| Node.js `@microsoft/opentelemetry` (1.0.0, 1.0.1, or 1.4.0 in these samples) | `a365: { useS2SEndpoint: true }` (or the distro's `Agent365Exporter` with the same option) |
| Legacy Node.js observability (compatible preview.115 or the sample's existing preview.125 API family) | `Agent365ExporterOptions.useS2SEndpoint = true` via `withExporterOptions`, plus the OBS-only app-token resolver |
| Python `microsoft-opentelemetry` | `a365_use_s2s_endpoint=True` |
| Python observability core 1.0.0 or later | `configure(exporter_options=Agent365ExporterOptions(use_s2s_endpoint=True, token_resolver=...))` |
| .NET `Microsoft.OpenTelemetry` 1.0.1 | `o.Agent365.Exporter.UseS2SEndpoint = true` |
| .NET `Microsoft.OpenTelemetry` 1.0.6 | `options.Agent365.UseS2SEndpoint = true` |
| Salesforce/Apex | Fixed S2S path; deprecated `UseS2SEndpoint__c` values cannot select the legacy route |

When supplying Python `exporter_options`, put the existing token resolver and any
cluster override **inside those options**; `configure` does not populate them on
an options object supplied by the caller.

The samples retain their published, API-compatible SDK families rather than
upgrading solely because a route lacks `/otlp`. Legacy Node.js SDKs use
`/observabilityService/tenants/{tenant}/agents/{agent}/traces`; the distro, Python,
.NET and Apex exporters use `/observabilityService/tenants/{tenant}/otlp/agents/{agent}/traces`.
The inspected service implements both route shapes with the same
`ExportTraceServiceRequest` body type. The legacy service route has distinct
tenant-eligibility/service-principal authorization policies: do not infer general
acceptance or caller-allowlist enforcement from the public OTLP role check.
Live authorization remains unverified for the available incomplete configuration.

Devin, Copilot Studio and Perplexity pin the coherent preview.115 SDK family to
retain their verified scope APIs. OpenAI and Vercel retain their existing
preview.125 dependency family. All five configure their legacy exporter once in
`src/otel.ts` and reject `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT`: that mode
would bypass the OBS-only resolver and read a context token. There is no need for
an unpublished SDK or a suffix-only payload migration.

LangChain's published distro 1.4.0 configuration disables `a365.durableDelivery`
because that release can otherwise replay historical route choices. Existing
spool data is not deleted. Do not re-enable replay until the installed release
enforces S2S for both live and replayed exports. No sample falls back to `/observability`.

**Authentication prerequisite (source-verified, not live-verified):** The inspected
S2S service contract accepts only service-principal/application tokens.
The public `/otlp/agents/` route requires `Agent365.Observability.OtelWrite` in
`roles`, not `scp`. Configure application-role consent rather than assuming a
tenant-specific permission exemption. Delegated AI Teammate/OBO tokens carrying
`scp` are rejected.
Selecting S2S does not convert a delegated token into an application token.
The presence of `scp` makes a token a user principal even if it also has `roles`
or an application-looking `idtyp`; additional permissions do not bypass this gate.

The autonomous/Salesforce examples acquire application tokens with `roles`.
Interactive samples now use a **separate OBS-only application-token provider**;
they do not obtain exporter tokens from business MCP/Graph/OBO caches. The provider
uses blueprint credentials plus `fmi_path` for the actual agent instance, then
exchanges that parent assertion through a second `client_credentials` grant for
the OBS audience. There is no `user_fic`, OBO assertion, or delegated-token fallback
in this flow. Business authentication and user context remain independent.

Node.js and Python interactive samples require `AGENT365_OBS_TENANT_ID`,
`AGENT365_OBS_AGENT_ID`, `AGENT365_OBS_BLUEPRINT_CLIENT_ID`, and
`AGENT365_OBS_BLUEPRINT_CLIENT_SECRET` when OBS export is enabled. Their supplied
credential flow is a development example; store secrets securely. .NET uses the
equivalent dedicated `Agent365Observability` configuration and additionally
supports managed-identity assertions. See each sample's template and README.
Providers reject missing/placeholder configuration, blueprint-as-agent IDs,
export identity mismatches, delegated tokens, wrong audiences and expired tokens.
They cache only valid app tokens until their actual expiry and fail explicitly
instead of returning empty or stale tokens. No provisioning or permissions are
changed by the samples.

Use the provisioned runtime Agent Identity, not the Agent Blueprint ID, for agent
attribution and the agent-bound OBS token flow. Incomplete provisioning is not a
valid AI Teammate test setup. A token-acquisition failure such as `AADSTS82001`
must be resolved before ingestion can be tested; changing the exporter URL cannot
repair a rejected token grant.
Do not fix a 401/403 by switching routes. Console/OTLP-only examples do not become
authenticated OBS examples merely by enabling the exporter; they also require
the dedicated application credentials and permissions.

**Validation:** Run the offline route/configuration regressions with
`python -m pytest tests/observability` from an environment with pytest and
`microsoft-agents-a365-observability-core>=1.0.0` installed (both are existing
Python sample dependencies). These inspect configuration, mock both token-exchange
requests, and mock HTTP exports for AI Teammate/OBO contexts, including failures,
cache expiry and identity mismatches, without starting agents or contacting services.
Node.js token-flow/route tests run with
`node --test tests/observability/node-app-token.test.cjs` after installing the
Node.js sample dependencies. .NET tests run with
`dotnet test tests/e2e/Agent365.E2E.Tests.csproj --filter FullyQualifiedName~ObservabilityAppTokenTests`.
Salesforce route/401 regressions extend `A365TelemetryTest` and require an
authorized test org. Live AI Teammate and OBO validation must independently check
the exported request's S2S path, audience, agent/tenant attribution, response,
and unchanged tool authentication; offline checks alone do not establish live
authorization success.
The S2S service also sanitizes `user.id` and its aliases unless the host/agent has
an authorized trusted-host or service exemption. Preserving user baggage in the
client's exported payload therefore does **not** prove that downstream OBO caller
attribution is retained. Validate attribution after ingestion using the approved
service configuration; do not alter identities to bypass this restriction.

The SDK versions used by each sample are displayed in the **E2E test workflow summaries**. Each E2E run installs the latest compatible packages and logs the resolved versions.

📦 **View SDK Versions**: Click any E2E status badge above, then select a workflow run and view the **"Log SDK Versions"** step in the job summary.

The samples use flexible version constraints (`>=`, `^`, `*-beta.*`) to automatically pick up the latest compatible SDK releases during each test run.
Most samples use flexible version constraints (`>=`, `^`, `*-beta.*`) to pick up
compatible SDK releases. Legacy Node.js samples pin their tested SDK family to
preserve compatible tracing APIs and S2S exporter options.

> #### Note:
> Use the information in this README to contribute to this open-source project. To learn about using this SDK in your projects, refer to the [Microsoft Agent 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/).
Expand Down
2 changes: 1 addition & 1 deletion agent-platforms/salesforce/apex-observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ script above). Secrets are **never** here — only in the External Credential en
| `IngestBase__c` | `https://agent365.svc.cloud.microsoft` | Reference value only; live ingest routing is controlled by the `A365_Obs_Ingest` Named Credential URL. |
| `ObsScope__c` | `api://9b975845-…/.default` | Observability API scope (public resource). |
| `FmiScope__c` | `api://AzureADTokenExchange/.default` | FMI token-exchange scope. |
| `UseS2SEndpoint__c` | `true` | Use the roles-enforced S2S ingest path. |
| `UseS2SEndpoint__c` | `true` | Deprecated compatibility field; OBS always uses `/observabilityService`, even when this field is `false` or unset. |
| `ServiceName__c` | `salesforce-apex` | `service.name` for boundary spans. |
| `AgentforceServiceName__c` | `salesforce-agentforce` | `service.name` for originated (Agentforce) spans. |
| `OriginateEnabled__c` | `false` | Enable the Agentforce origination path (see `agent/`). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,13 @@ public with sharing class A365ObsConfig {
}

public static Boolean useS2SEndpoint() {
A365_Observability_Config__mdt c = getInstance();
// Default to the S2S path (roles-enforced) when unspecified.
return c == null || c.UseS2SEndpoint__c == true;
// Compatibility accessor: the deprecated metadata flag no longer selects a route.
return true;
}

// The ingest path for the OTLP traces POST, honoring UseS2SEndpoint__c.
// OBS always uses S2S, including records with the legacy flag set to false.
public static String tracesPath() {
String svc = useS2SEndpoint() ? 'observabilityService' : 'observability';
return '/' + svc + '/tenants/' + tenantId()
return '/observabilityService/tenants/' + tenantId()
+ '/otlp/agents/' + agentId() + '/traces?api-version=1';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ private class A365TelemetryTest {
String tok = (tokenCalls == 1) ? 'FAKE_T1' : 'FAKE_OBS_TOKEN';
res.setBody('{"access_token":"' + tok + '","expires_in":3599}');
} else {
System.assertEquals(
'callout:A365_Obs_Ingest/observabilityService/tenants/' + TENANT
+ '/otlp/agents/' + AGENT + '/traces?api-version=1',
req.getEndpoint(), 'OBS must never fall back to the legacy route');
ingestCalls++;
lastIngestBody = req.getBody();
lastAuthHeader = req.getHeader('Authorization');
Expand Down Expand Up @@ -65,6 +69,35 @@ private class A365TelemetryTest {
return (String) attrs.get('service.name');
}

@IsTest
static void tracesPath_ignoresLegacyFlag() {
String expected = '/observabilityService/tenants/' + TENANT
+ '/otlp/agents/' + AGENT + '/traces?api-version=1';
for (Boolean legacyValue : new List<Boolean>{ true, false, null }) {
A365ObsConfig.overrideRecord = cfg(true);
A365ObsConfig.overrideRecord.UseS2SEndpoint__c = legacyValue;
System.assertEquals(true, A365ObsConfig.useS2SEndpoint());
System.assertEquals(expected, A365ObsConfig.tracesPath());
}
}

@IsTest
static void emitToolSpan_legacyFalse_andUnauthorized_neverFallsBack() {
A365ObsConfig.overrideRecord = cfg(true);
A365ObsConfig.overrideRecord.UseS2SEndpoint__c = false;
A365ObsToken.clearCache();
MultiMock mock = new MultiMock();
mock.ingestStatus = 401;
Test.setMock(HttpCalloutMock.class, mock);

Test.startTest();
A365Telemetry.emitToolSpan(
TRACEPARENT, 'execute_tool A365ToolRest', 'SERVER', null, 1L, 2L, true);
Test.stopTest();

System.assertEquals(1, mock.ingestCalls, '401 must not retry on another route');
}

@IsTest
static void emitToolSpan_enabled_enqueuesAndPostsSpanWithInboundTrace() {
A365ObsConfig.overrideRecord = cfg(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>UseS2SEndpoint__c</fullName>
<defaultValue>true</defaultValue>
<description>true -> /observabilityService path (S2S, roles claim enforced); false -> /observability.</description>
<label>Use S2S Endpoint</label>
<description>Deprecated compatibility field. OBS always uses /observabilityService; false is ignored and never selects the legacy route.</description>
<label>Use S2S Endpoint (Deprecated)</label>
<type>Checkbox</type>
</CustomField>
28 changes: 3 additions & 25 deletions dotnet/agent-framework/sample-agent/Agent/MyAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT License.

using Agent365AgentFrameworkSampleAgent.Tools;
using Microsoft.Agents.A365.Observability.Hosting.Caching;
using Microsoft.Agents.A365.Observability.Runtime.Common;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Contracts;
using Microsoft.Agents.A365.Observability.Runtime.Tracing.Scopes;
Expand Down Expand Up @@ -65,7 +64,6 @@ private static string GetAgentInstructions(string? userName)
private readonly IConfiguration? _configuration = null;
private readonly ILogger<MyAgent>? _logger = null;
private readonly IMcpToolRegistrationService? _toolService = null;
private readonly IExporterTokenCache<AgenticTokenStruct>? _agentTokenCache = null;
// Setup reusable auto sign-in handlers for user authorization (configurable via appsettings.json)
private readonly string? AgenticAuthHandlerName;
private readonly string? OboAuthHandlerName;
Expand Down Expand Up @@ -102,13 +100,11 @@ private static bool ShouldSkipToolingOnErrors()
public MyAgent(AgentApplicationOptions options,
IChatClient chatClient,
IConfiguration configuration,
IExporterTokenCache<AgenticTokenStruct> agentTokenCache,
IMcpToolRegistrationService toolService,
ILogger<MyAgent> logger) : base(options)
{
_chatClient = chatClient;
_configuration = configuration;
_agentTokenCache = agentTokenCache;
_logger = logger;
_toolService = toolService;

Expand Down Expand Up @@ -231,7 +227,7 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta
var resolvedTenantId = turnContext.Activity.Conversation?.TenantId
?? turnContext.Activity.Recipient?.TenantId;

// Only set baggage / register a token / open InvokeAgentScope when we have a real
// Only set baggage / open InvokeAgentScope when we have a real
// (agent, tenant) tuple. Falling back to Guid.Empty creates a synthetic identity
// group the exporter cannot authenticate and pollutes the trace with orphan spans.
var hasObservabilityIdentity = !string.IsNullOrEmpty(resolvedAgentId)
Expand All @@ -244,26 +240,8 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta
.Build()
: null;

// Register an OBO token resolver for this (agent, tenant) tuple so the Agent365 exporter
// can authenticate when POSTing traces. Mirrors the demo's A365OtelWrapper.
if (hasObservabilityIdentity)
{
try
{
_agentTokenCache?.RegisterObservability(
resolvedAgentId!,
resolvedTenantId!,
new AgenticTokenStruct(
userAuthorization: UserAuthorization,
turnContext: turnContext,
authHandlerName: ToolAuthHandlerName ?? string.Empty),
EnvironmentUtils.GetObservabilityAuthenticationScope());
}
catch (Exception ex)
{
_logger?.LogWarning("Failed to register observability token: {Message}", ex.Message);
}
}
// The exporter uses its separate app-only provider; business OBO tokens and
// the original turn identity above are never substituted for OBS credentials.

// Send an immediate acknowledgment — this arrives as a separate message before the LLM response.
// Each SendActivityAsync call produces a discrete Teams message, enabling the multiple-messages pattern.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<!-- Agent Framework Packages -->
<PackageReference Include="AdaptiveCards" Version="3.1.0" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" Aliases="global,ObservabilityIdentity" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
<PackageReference Include="Microsoft.Agents.Authentication.Msal" Version="1.4.83" />
<PackageReference Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.4.83" />
Expand All @@ -37,4 +37,8 @@
<Folder Include="msteams\" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\..\shared\Observability\*.cs" Link="Observability\%(Filename)%(Extension)" />
</ItemGroup>

</Project>
7 changes: 6 additions & 1 deletion dotnet/agent-framework/sample-agent/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Agent365AgentFrameworkSampleAgent;
using Agent365AgentFrameworkSampleAgent.Agent;
using Agent365.Samples.Observability;
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services;
Expand All @@ -19,6 +20,8 @@


var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly());
using var observabilityTokens = ObservabilityAppTokenFactory.Create(builder.Configuration);

// Configure OpenTelemetry distro — Console exporter only in Development to avoid PII leaks
builder.UseMicrosoftOpenTelemetry(o =>
Expand All @@ -27,14 +30,16 @@
? ExportTarget.Agent365 | ExportTarget.Console
: ExportTarget.Agent365;

o.Agent365.Exporter.UseS2SEndpoint = true;
o.Agent365.Exporter.TokenResolver = observabilityTokens.ResolveAsync;

// Agent365-only export suppresses infrastructure instrumentation by default.
// Re-enable explicitly so HTTP calls (Azure OpenAI, auth, Teams) appear in traces.
o.Instrumentation.EnableAspNetCoreInstrumentation = true;
o.Instrumentation.EnableHttpClientInstrumentation = true;
o.Instrumentation.EnableAzureSdkInstrumentation = true;
});

builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly());
builder.Services.AddControllers();
builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600));
builder.Services.AddHttpContextAccessor();
Expand Down
Loading
Loading