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
6 changes: 5 additions & 1 deletion .github/workflows/ci-nodejs-openai-sampleagent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ jobs:
run: npm install

- name: Build
run: npm run build
run: npm run build

- name: Verify business authentication contracts
working-directory: .
run: node --test --test-name-pattern="business (tool/OBO authorization|auth contract)" tests/observability/node-app-token.test.cjs
Comment thread
DheerajPannala marked this conversation as resolved.
52 changes: 52 additions & 0 deletions .github/workflows/ci-observability.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

name: CI - Observability Offline Tests
permissions:
contents: read

on:
workflow_call: # Called by orchestrator
workflow_dispatch: # Manual trigger

jobs:
python-observability:
name: Python observability tests
runs-on: windows-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --pre -e "./python/openai/sample-agent[dev]"
pip install requests

- name: Run observability tests
run: python -m pytest tests/observability -q

dotnet-observability:
name: .NET ObservabilityAppTokenTests
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore test dependencies
run: dotnet restore tests/e2e/Agent365.E2E.Tests.csproj

- name: Run ObservabilityAppTokenTests
run: dotnet test tests/e2e/Agent365.E2E.Tests.csproj --no-restore --filter FullyQualifiedName~ObservabilityAppTokenTests
7 changes: 6 additions & 1 deletion .github/workflows/ci-orchestrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ jobs:
name: Python Claude
uses: ./.github/workflows/python-claude-sample.yml

observability:
name: Observability Offline Tests
uses: ./.github/workflows/ci-observability.yml

# Final status check - ALWAYS runs and reports success
# This is the ONLY check you need to mark as "Required" in branch protection
ci-status:
Expand All @@ -72,14 +76,15 @@ jobs:
- python-googleadk
- python-openai
- python-claude
- observability
if: always()
steps:
- name: Check CI Status
run: |
echo "Checking CI job results..."

# Get all job results (skipped jobs are fine, failed jobs are not)
results="${{ needs.dotnet-agentframework.result }} ${{ needs.dotnet-semantickernel.result }} ${{ needs.nodejs-claude.result }} ${{ needs.nodejs-langchain.result }} ${{ needs.nodejs-openai.result }} ${{ needs.nodejs-vercelsdk.result }} ${{ needs.python-agentframework.result }} ${{ needs.python-googleadk.result }} ${{ needs.python-openai.result }} ${{ needs.python-claude.result }}"
results="${{ needs.dotnet-agentframework.result }} ${{ needs.dotnet-semantickernel.result }} ${{ needs.nodejs-claude.result }} ${{ needs.nodejs-langchain.result }} ${{ needs.nodejs-openai.result }} ${{ needs.nodejs-vercelsdk.result }} ${{ needs.python-agentframework.result }} ${{ needs.python-googleadk.result }} ${{ needs.python-openai.result }} ${{ needs.python-claude.result }} ${{ needs.observability.result }}"

echo "Job results: $results"

Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/python-claude-sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ jobs:
python -m py_compile start_with_generic_host.py
python -m py_compile agent_interface.py
python -m py_compile local_authentication_options.py
python -m py_compile token_cache.py
python -m py_compile observability_config.py
python -m py_compile turn_context_utils.py
python -m py_compile mcp_tool_registration_service.py
Expand Down Expand Up @@ -71,7 +70,6 @@ jobs:
try:
import agent_interface
import local_authentication_options
import token_cache
import observability_config
import turn_context_utils
import mcp_tool_registration_service
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@ This repository contains sample agents and prompts for building with the Microso

## SDK Versions

### Observability S2S export

Agent 365 OBS export uses the S2S `/observabilityService/.../otlp/...` route with an app-only token for the agent instance. This changes telemetry transport only: preserve business MCP/Graph/OBO authentication, development bearer-token flows, and original user/agent baggage.

A live validation on September 28, 2026 showed that a registered agent instance using a roleless app-only token (`idtyp=app`, `roles=[]`, no `scp`) received `200` from `/observabilityService/tenants/{tenant}/otlp/agents/{agent}/traces`. The legacy non-`/otlp` S2S route (`/observabilityService/tenants/{tenant}/agents/{agent}/traces`) rejected the same token with `401` (`AuthenticationSchemeNotSupported`). Every sample must use an SDK/exporter configuration that posts to `/otlp`.

Enable export explicitly and configure the dedicated OBS app-token provider for the runtime agent instance, not the blueprint ID, service-principal object ID, or agent-user ID. The provider accepts app-only tokens with `idtyp=app`, or valid nonempty `roles`, or absent `idtyp` with nonempty `oid == sub`; any `scp` claim is rejected.

The sample providers are single-instance examples: one configured tenant and agent instance, plus a separate blueprint credential. Python and Node.js samples include only a client-secret development flow and no managed-identity option; .NET can use managed-identity assertions. Multi-instance or multi-tenant deployments should cache per agent/tenant and reuse the hosting connection credential. Providers request tokens from `login.microsoftonline.com`; sovereign clouds require provider changes.

AI Teammates should complete the `Agent365.Observability.OtelWrite` application-role step printed by `a365 setup all --aiteammate`; AI Teammate S2S export without that step has not been validated.

See [Agent 365 observability S2S export](docs/observability-s2s.md) for configuration snippets, token contract details, and offline validation commands.

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
16 changes: 13 additions & 3 deletions agent-platforms/salesforce/apex-observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ Because MSAL is unavailable in Apex, the sample hand-rolls the **S2S OAuth FMI 3
All emission is **fail-open**, **async**, and **config-gated**, so telemetry never affects the
business response.

The application-token flow does not require a client-side `roles` claim. The
public S2S OTLP endpoint can authorize eligible Agent 365-registered instances
without an OBS-specific role grant, subject to service policy. Register the exact
runtime instance; an Entra identity alone is insufficient. For 401/403, check
identity, audience, registration and service policy instead of switching to OBO
or automatically adding OBS permissions. Apex runtime tests require an authorized
Salesforce test org; an offline source audit is not live authorization validation.

For comprehensive documentation, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/).

## What This Sample Demonstrates
Expand Down Expand Up @@ -73,8 +81,10 @@ Design rules (all enforced in code):
| **Authentication** | App-based (S2S OAuth to Microsoft Entra, hand-rolled in Apex) |
| **Identity** | Agent identity (token `azp` == agent id) |

The Agent 365 ingest requires an **agent-bound** token (`{agentId}` in the URL == token `azp`, plus the
app-role claim), minted via an **FMI 3-hop** (2 token POSTs) sponsored by the agent **blueprint** app —
The Agent 365 ingest requires an app-only **agent-bound** token (`{agentId}` in the
URL == token `azp`/`appid`), minted via an **FMI 3-hop** (2 token POSTs) sponsored by
the agent **blueprint** app. OBS authorization depends on registration and service
policy; an OBS role is not a universal prerequisite. The token exchange uses
JWT-bearer client-credentials, not OBO. See [Token model](docs/design.md#token-model-fmi-3-hop-agent-bound)
for the exact per-hop requests and Named Credentials.

Expand Down Expand Up @@ -177,7 +187,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
12 changes: 8 additions & 4 deletions agent-platforms/salesforce/apex-observability/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,20 @@ Dependency direction (no cycles):
> The steps below show only what the Apex sample sends on the wire.

MSAL is unavailable in Apex, so each hop is a raw `application/x-www-form-urlencoded` POST. The ingest
enforces `{agentId}`-in-URL == token `azp`/`appid` plus the app-role claim, so a plain dedicated-app
token is rejected (403). The sample therefore mints an **agent-bound** token:
enforces `{agentId}`-in-URL == token `azp`/`appid` and app-only identity, so a token
for an unrelated dedicated app is not sufficient. Eligible registered agent
instances can use roleless tokens on this public S2S route when service policy
permits. An Entra identity alone does not establish Agent 365 registration.
The sample therefore mints an **agent-bound** token:

1. **Hop 1/2** — as the blueprint app: `client_credentials` + `scope=api://AzureADTokenExchange/.default` +
`fmi_path=<agentId>`, client auth = `Basic` (from the External Credential). Yields a T1 FMI
assertion. (`A365_Obs_Token` Named Credential.)
2. **Hop 3** — as the agent id: `client_credentials` + `client_id=<agentId>` +
`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` +
`client_assertion=T1` + `scope=<obs>/.default`. Yields the agent-bound token (`azp=agentId`,
`roles=[Agent365.Observability.OtelWrite]`). (`A365_Obs_TokenJwt` Named Credential.)
`client_assertion=T1` + `scope=<obs>/.default`. Yields an app-only agent-bound
token (`azp`/`appid=agentId`), which may have no `roles` claim.
(`A365_Obs_TokenJwt` Named Credential.)
3. **Ingest** — `POST {ingestBase}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`
with `Authorization: Bearer <token>`. (`A365_Obs_Ingest` Named Credential.)

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 @@ -3,8 +3,9 @@

// A365ObsToken — acquires an Agent 365 *agent-bound* Observability token from Apex.
//
// The ingest service enforces `{agentId}` in the URL == token `azp`/`appid` and requires
// the app-role (`roles`) claim, so a plain dedicated-app token 403s. We mint an
// The ingest service enforces `{agentId}` in the URL == token `azp`/`appid`.
// Roleless authorization requires eligible instance registration and service policy.
// A token for an unrelated dedicated app is not an agent-bound token. We mint an
// agent-bound token via the FMI 3-hop (JWT-bearer client-credentials), sponsored by the
// agent BLUEPRINT app (which the agent identity already federates):
//
Expand Down
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>
69 changes: 69 additions & 0 deletions docs/observability-s2s.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Agent 365 observability S2S export

The samples export Agent 365 observability data through the S2S `/observabilityService/.../otlp/...` route with an app-only token for the agent instance. This changes telemetry transport only: keep business MCP, Graph, OBO, bearer-token development flows, and original turn baggage separate.

## Route and token contract

Live validation on September 28, 2026 showed that a registered agent instance using a roleless app-only token (`idtyp=app`, `roles=[]`, no `scp`) received `200` from `/observabilityService/tenants/{tenant}/otlp/agents/{agent}/traces`. The legacy non-`/otlp` S2S route (`/observabilityService/tenants/{tenant}/agents/{agent}/traces`) rejected the same token with `401` (`AuthenticationSchemeNotSupported`). Every sample must use an SDK/exporter configuration that posts to the `/otlp` route.

The sample providers accept app-only tokens that meet one of these contracts:

- `idtyp=app`
- a valid nonempty `roles` array when `idtyp` is absent
- absent `idtyp` with a nonempty `oid` equal to `sub`

Any `scp` claim is rejected, even if it is empty or the token also contains application-looking claims. When present, `roles` must be an array of nonblank strings.

## Sample provider scope

The .NET, Python, and Node.js sample providers are intentionally simple and single-instance: they export for one statically configured tenant and agent instance. Configure the agent instance client ID, not the blueprint ID, service-principal object ID, or agent-user ID.

These sample providers need their own copy of the blueprint credential. The Python and Node.js samples only include a client-secret development flow and have no managed-identity option; the .NET samples can use a managed-identity assertion for the blueprint credential. All providers currently request tokens from `login.microsoftonline.com`, so sovereign clouds need provider changes before use.

For production deployments that can serve multiple hired instances or tenants, implement a per-agent/per-tenant cache and reuse the hosting connection credential for that turn's agent identity. Do not rewrite incoming baggage to fit a static configuration.

## Configuration

Enable export explicitly. Keep the checked-in placeholders for local/Playground runs with export disabled.

### .NET

```json
{
"EnableAgent365Exporter": true,
"Agent365Observability": {
"TenantId": "<<AGENT_HOME_TENANT_ID>>",
"AgentId": "<<AGENT_INSTANCE_CLIENT_ID>>",
"BlueprintClientId": "<<BLUEPRINT_CLIENT_ID>>",
"UseManagedIdentity": true,
"ManagedIdentityClientId": ""
}
}
```

For local development with a secret, set `UseManagedIdentity=false` and provide `Agent365Observability:BlueprintClientSecret` through user secrets or `Agent365Observability__BlueprintClientSecret`.

### Python and Node.js

```dotenv
ENABLE_A365_OBSERVABILITY_EXPORTER=true
AGENT365_OBS_TENANT_ID=<<YOUR_TENANT_ID>>
AGENT365_OBS_AGENT_ID=<<YOUR_AGENT_INSTANCE_CLIENT_ID>>
AGENT365_OBS_BLUEPRINT_CLIENT_ID=<<YOUR_BLUEPRINT_CLIENT_ID>>
AGENT365_OBS_BLUEPRINT_CLIENT_SECRET=<<YOUR_BLUEPRINT_CLIENT_SECRET>>
```

The Python `microsoft-opentelemetry` distro gates its A365 HTTP exporter on `ENABLE_A365_OBSERVABILITY_EXPORTER` or `a365_enable_observability_exporter`; when disabled, A365 span enrichment can remain enabled without sending data to A365.

## AI Teammates

AI Teammate S2S export without the `Agent365.Observability.OtelWrite` application-role step has not been validated. For AI Teammates, complete the OtelWrite application-role assignment that `a365 setup all --aiteammate` prints.

## Offline validation

```powershell
python -m pytest tests/observability -q
dotnet test tests/e2e/Agent365.E2E.Tests.csproj --filter FullyQualifiedName~ObservabilityAppTokenTests
```

The Python suite mocks token exchange and exporter uploads. The .NET suite mocks HTTP token exchange and validates the sample-local provider copies.
Loading
Loading