microsoft/instrument-observability
> Instruments Microsoft Agent 365 observability into existing .NET AgentFramework, Node.js, or Python agents. Adds OTel-based tracing, context propagation, A365 exporter, manual instrumentation scopes (InvokeAgentScope, InferenceScope, ExecuteToolScope — required for store publishing), and updates configuration files. Asks a two-stage question — agent kind (AI Teammate or Agent (Non AI Teammate)) and auth mode — to determine (FMI 3-hop token chain with Power Platform scope supported for .NET, Node.js, and Python — each language gets a scaffold token-service file that acquires and refreshes the Observability API token via the FMI chain). Non-destructive and idempotent.
npx skills add https://github.com/microsoft/agent365-skills --skill instrument-observability
> Trigger phrases — any of these will activate this skill automatically:
> - "instrument observability for this agent"
> - "add a365 observability to this agent"
> - "add observability to this agent"
> - "set up tracing for this agent"
> - "make this agent visible in microsoft defender"
> - "enable agent 365 telemetry"
> - "wire up opentelemetry for this agent"
> - "add observability to this .net agent"
> - "add observability to this node.js agent"
> - "add a365 observability to this python agent"
This skill instruments Microsoft Agent 365 observability into an existing agent codebase
without disrupting the agent's core logic. It:
> Store publishing requirement: The Agent 365 store validation requires InvokeAgentScope,
> InferenceScope, and ExecuteToolScope to be implemented. This skill wires them.
All changes are additive and idempotent — rerunning the skill is safe.
> Task-list display (applies throughout this skill). This skill creates tasks inline via TaskCreate — "..." markers at the start of each phase, and marks them complete at phase end. The user must see this progress visibly. Each TaskCreate line corresponds to one checklist item; exactly one item in_progress at a time.
> - Claude Code: TaskCreate is in allowed-tools — calling it renders a native checklist UI; subsequent TaskUpdate calls flip statuses.
> - VS Code Copilot Chat / GitHub Copilot CLI: allowed-tools is ignored — before Phase 0.1, scan this SKILL.md for all TaskCreate — "..." lines and emit a markdown checklist in chat upfront (- [ ] Load detection cache…, - [ ] Determine agent kind…, etc.); flip items to - [x] as each phase completes.
TaskCreate — "Load detection cache and validate with user"
Run in parallel:
/*.csproj, package.json, requirements.txt, pyproject.toml, src//*.ts, /*.cs, /*.py → hasProjectFiles..a365-workspace-detection.local.json → cacheState (fresh if detectedAt < 60 min, stale if older, missing if absent).Decide:
| cacheState | hasProjectFiles | Action |
|--------------|-------------------|--------|
| fresh | — | Continue to Step 0.2 below. |
| missing / stale | false | Hard stop with a useful message: *"This skill instruments an existing agent for observability — there's no agent code in this workspace yet. Run /agent365:make-ai-teammate (if this will be a Teams/Copilot agent) or /agent365:make-a365-agent first to scaffold and register the agent, then come back here."* Do not proceed. |
| missing / stale | true | Tell the user: *"Found existing agent code but no fresh Agent 365 registration. I'll run a365-setup now to register it and write the detection cache, then continue here automatically."* Read ${CLAUDE_PLUGIN_ROOT}/skills/a365-setup/SKILL.md and follow it through completion, then continue to Step 0.2. |
🛑 STOP — .a365-workspace-detection.local.json MUST exist before this step. Read the file path .a365-workspace-detection.local.json in the working directory. If it does not exist, you arrived at Step 0.2 by skipping Step 0.1's triage routing. Do NOT proceed. Do NOT invent default cache values. Do NOT run any further phase (no npm install, no dotnet add package, no pip install, no file edits). Instead:
a365-setup now to fix that, then I'll return here."*${CLAUDE_PLUGIN_ROOT}/skills/a365-setup/SKILL.md and follow it to completion.The stop hook (validate-instrument-observability.js) will fail the session at end if the cache file is missing — this guard exists so the model halts immediately rather than instrumenting against unknown authMode.
Load from cache: agentStack, programmingLanguage, usesTeamsOrCopilot, agentType, authMode (if previously stored).
Present the loaded values in one message and wait for confirmation:
Here's what we detected about your agent:
• Stack: {agentStack}
• Language: {programmingLanguage}
Reply **yes** to confirm, or describe any corrections.
TaskUpdate — Mark complete: "Load detection cache and validate with user"
TaskCreate — "Determine agent kind and authentication mode"
Read ${CLAUDE_PLUGIN_ROOT}/shared/agent-detection.md — section "Agent Type and Auth Mode Detection" — and follow it exactly.
If agentType and authMode are already present in the detection cache (from a prior skill run in this session OR pre-populated by a parent skill like make-ai-teammate), the confirmation behavior depends on agentType:
agentType = "ai-teammate" — skip the confirmation prompt entirely. The AI Teammate identity model is unambiguous (authMode = agentic-user, no obo/s2s decision exists), so a confirm prompt adds friction without catching drift. Proceed silently.agentType = "system-agent" — confirm the cached values with the user before proceeding, since the obo/s2s choice is meaningful and a stale value would silently route to the wrong token path.Read authMode case-insensitively (S2S = s2s, OBO = obo); always write back the canonical lowercase value.
Store agentType (ai-teammate = AI Teammate, or system-agent = Agent (Non AI Teammate)) and authMode:
agentic-user (agent's own M365 identity — not the caller's token; auto-set, no question needed)obo (On-Behalf-Of — signed-in user token) or s2s (Service Principal, no user token)Update .a365-workspace-detection.local.json — merge agentType and authMode into the existing cache file, preserving all other fields (agentStack, programmingLanguage, usesTeamsOrCopilot, detectedAt). Use the Write tool to write the merged object back.
The authMode value drives Phases 3–5: OBO and S2S paths differ in entry point wiring (Phase 3), message handler pattern (Phase 4), and token resolver (Phase 5). Phases 2, 6, 7, and 8 are identical regardless of authMode.
TaskUpdate — Mark complete: "Determine agent type and authentication mode"
TaskCreate — "Detect agent type and load reference patterns"
${CLAUDE_PLUGIN_ROOT}/shared/agent-detection.md for detection heuristics.agent-detection.md:.NET AgentFramework indicators (Microsoft.Agent.*, AgentFramework) → .csprojNode.js indicators (package.json, @langchain, openai, @microsoft/agents-*)Python indicators (requirements.txt, pyproject.toml, .py files, microsoft-agents)${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/dotnet-observability.md${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/nodejs-observability.md${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/python-observability.md.a365setup-unknown-agent and exit early with clear error message.(programmingLanguage, agentStack) from the detection cache and surface a warning when the stack lacks first-class auto-instrumentation in @microsoft/opentelemetry or microsoft-opentelemetry. The skill still proceeds — observability is the OTel SDK underneath, which works for any HTTP-based LLM — but the user should know they'll need to add manual InferenceScope.start wrappers around each LLM call.| Lang | Stack | Action |
|---|---|---|
| Node.js | LangChain, OpenAI Agents SDK, Claude SDK | ✅ Auto-instrumented (Claude with custom shape — see Phase 5.5) |
| Node.js | Semantic Kernel, Google ADK | ⚠ Soft-warn — auto-instrumentation may not patch the LLM library; add manual InferenceScope.start around each LLM call |
| Python | Agent Framework, OpenAI, Google ADK | ✅ Auto-instrumented |
| Python | LangChain, Claude SDK, CrewAI | ⚠ Soft-warn — same as Node.js SK/ADK |
| .NET | Agent Framework, Semantic Kernel | ✅ Auto-instrumented via .UseOpenTelemetry() on IChatClient |
| .NET | Azure AI Foundry | ⚠ Soft-warn — best-effort wiring |
For soft-warn rows, surface verbatim: *"Auto-instrumentation in <unified-distro-package> doesn't patch your LLM library directly. The skill will still wire useMicrosoftOpenTelemetry/UseMicrosoftOpenTelemetry (OTel SDK + A365 exporter), but you'll need to manually wrap each LLM call with InferenceScope.start(...) to capture gen_ai.* spans. See <language>-observability.md § 'InferenceScope — Manual Wrapping' for the pattern."* Continue to Phase 2.
TaskCreate — "Install A365 observability packages"
All languages converge on a single unified distro that re-exports the legacy
A365 observability + hosting types and auto-instruments common LLM SDKs:
| Language | Install command | S2S extra (FMI token chain) |
|----------|-----------------|------------------------------|
| .NET | dotnet add package Microsoft.OpenTelemetry | dotnet add package Azure.Identity Microsoft.Identity.Client |
| Node.js | npm install @microsoft/opentelemetry | npm install @azure/msal-node @azure/identity |
| Python | pip install microsoft-opentelemetry | pip install msal azure-identity httpx |
Do not install legacy *.Observability.Runtime / -hosting / -extensions-*
packages alongside the unified distro — the distro re-exports their types and
mixing the two produces CS0433 duplicate-type errors (.NET) or duplicate spans
(Node.js / Python). After install, verify the package appears in the manifest
(*.csproj / package.json / requirements.txt or pyproject.toml).
pip install does not update the dependency manifest — prefer
uv add microsoft-opentelemetry (or poetry add ...) for Python.
Python — Google ADK gotcha: if pyproject.toml lists google-adk,
uv sync will backtrack for minutes resolving the OTel graph. Pin OTel via
[tool.uv] override-dependencies — see python-observability.md → "Google ADK
projects — pin the OTel stack" for the exact block. Other Python stacks
(AgentFramework, LangChain, OpenAI, Claude, Semantic Kernel) don't need this.
Full per-language package tables, version constraints, and the LangChain extras
flag live in the references — see the "Required packages" section of:
${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/dotnet-observability.md${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/nodejs-observability.md${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/python-observability.mdTaskUpdate — Mark complete.
TaskCreate — "Wire observability in entry point"
> Pre-existing placeholders: As of CLI 1.1, a365 setup all auto-writes Agent365Observability placeholder sections to appsettings.json (.NET) or .env (Node.js/Python). Before creating config from scratch, check if placeholders already exist and fill in values rather than duplicating the section.
Program.cs or detected file).dotnet-observability.md:using Microsoft.OpenTelemetry; to Program.cs.builder.UseMicrosoftOpenTelemetry(o => { ... }) with o.Exporters = ExportTarget.Agent365 | ExportTarget.Console (Dev) or ExportTarget.Agent365 (Production). The distro auto-registers IExporterTokenCache<AgenticTokenStruct> in DI — no AddAgenticTracingExporter() call needed. Leave o.Agent365.Exporter.UseS2SEndpoint at its default (false) — the exporter POSTs to /observability/ which the OBO token cache authenticates. Also set "EnableAgent365Exporter": true in appsettings.json to activate the backend exporter — the SDK defaults this to false when absent, so without it the exporter is wired but inert.IChatClient.UseOpenTelemetry() — when registering the IChatClient (e.g. Azure OpenAI), chain .AsBuilder().UseFunctionInvocation().UseOpenTelemetry(sourceName: null, cfg => cfg.EnableSensitiveData = true).Build(). This is what makes the AI SDK emit the gen_ai.inference and gen_ai.tool spans that InvokeAgentScope (Phase 5.5) anchors as children. Skipping this means no LLM spans appear in MAC, even with everything else wired — the InvokeAgent parent becomes a hollow span. EnableSensitiveData = true includes prompts/completions in span attributes (PII consideration — set to false for regulated data).Observability/ObservabilityServiceExtensions.cs (DI extension with AddAgent365Observability() using ServiceTokenCache and conditional ObservabilityTokenService) and Observability/ObservabilityTokenService.cs (background service that acquires the Observability API token via the MSAL FMI 3-hop chain with .WithFmiPath() targeting scope api://9b975845-388f-4429-889e-eab1ef63949c/.default, supports MSI with client-secret fallback). Then call builder.Services.AddAgent365Observability(); and builder.UseMicrosoftOpenTelemetry(...) with token resolver reading from the ServiceTokenCache. Critical: Set o.Agent365.Exporter.UseS2SEndpoint = true in the options callback — without this, the exporter posts to the wrong path (/observability/ instead of /observabilityService/) and gets HTTP 401. See "Known Issues" section.adapter.Use(new BaggageTurnMiddleware()) (OBO path only) to auto-populate baggage on every request// A365 Observability — best-effort instrumentation (verify against official sample)index.ts, app.ts, or detected file).nodejs-observability.md:useMicrosoftOpenTelemetry, shutdownMicrosoftOpenTelemetry, configureA365Hosting, and AgenticTokenCacheInstance — all from @microsoft/opentelemetry (single package as of GA 1.0; do NOT import from the legacy -observability, -hosting, or -runtime packages).useMicrosoftOpenTelemetry({ a365: { enabled: true, enableObservabilityExporter: true, tokenResolver } }) before any LLM/framework imports. Both enabled: true AND enableObservabilityExporter: true are required in 1.0+ to actually export spans. Wire tokenResolver to AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId) ?? ''.observability/token-cache.ts (in-memory token cache with cacheToken/getCachedToken/tokenResolver) and observability/observability-token-service.ts using the scaffold pattern from nodejs-observability.md (S2S section). This module acquires the Observability API token via MSAL FMI 3-hop chain (@azure/msal-node with fmiPath parameter, targeting scope api://9b975845-388f-4429-889e-eab1ef63949c/.default, supports MSI with client-secret fallback) and refreshes it every 50 min. Then call useMicrosoftOpenTelemetry({ a365: { enabled: true, enableObservabilityExporter: true, useS2SEndpoint: true, tokenResolver: a365TokenResolver } }). useS2SEndpoint: true is now a first-class option (1.0+); the old workaround with custom Agent365Exporter via spanProcessors and ENABLE_A365_OBSERVABILITY_EXPORTER=false is no longer needed for new instrumentation. If the old workaround is already present in an existing agent, leave it in place — do not delete code as part of this additive skill; flag it in the final summary as a candidate for cleanup if the user explicitly asks to migrate.configureA365Hosting(adapter, { enableBaggage: true }) once at startup to register BaggageMiddleware. This replaces manual adapter.use(new BaggageMiddleware()) and removes the need for BaggageBuilderUtils.fromTurnContext in handlers.SIGTERM/SIGINT handlers calling await shutdownMicrosoftOpenTelemetry() to flush pending spans on shutdown.OpenAIAgentsTraceInstrumentor.enable() or LangChainTraceInstrumentor.instrument() — these are auto-enabled in 1.0+ and manual calls cause duplicate spans. To opt out, set instrumentationOptions: { openaiAgents: { enabled: false } }.// A365 Observability — best-effort instrumentation (verify against official sample)app.py, host_agent_server.py, or detected file).python-observability.md:use_microsoft_opentelemetry from microsoft.opentelemetry (single unified package; do NOT import from the legacy microsoft_agents_a365.* namespace).use_microsoft_opentelemetry(enable_a365=True, a365_enable_observability_exporter=True, a365_token_resolver=...). Both enable_a365=True AND a365_enable_observability_exporter=True are required in 1.0+ to actually export spans. Wire a365_token_resolver to AgenticTokenCache().get_observability_token from microsoft.opentelemetry.a365.hosting.token_cache_helpers (or a custom resolver reading from token_cache.py).observability/token_cache.py (in-memory token cache with cache_token/get_cached_token) and observability/observability_token_service.py using the scaffold pattern from python-observability.md (S2S section). This module acquires the Observability API token via a 3-hop FMI chain: direct HTTP POST with fmi_path for Hops 1+2 (MSAL Python does not properly serialize fmi_path — known limitation), then msal.ConfidentialClientApplication for Hop 3, targeting scope api://9b975845-388f-4429-889e-eab1ef63949c/.default, supports MSI with client-secret fallback, refreshes every 50 min via an asyncio background task. Then call use_microsoft_opentelemetry(enable_a365=True, a365_enable_observability_exporter=True, a365_use_s2s_endpoint=True, a365_token_resolver=...). a365_use_s2s_endpoint=True is now a first-class kwarg — no workaround needed. Schedule run_token_service() as an asyncio task and call acquire_initial_token() in your aiohttp lifespan startup. Also install msal, azure-identity, and httpx.ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions(enable_baggage=True)) once at startup to auto-populate baggage from TurnContext. Note: enable_baggage defaults to False — must be explicitly set to True.*Instrumentor().instrument() methods for LangChain/OpenAI/SK/AgentFramework — these are auto-enabled in 1.0+ and manual calls cause duplicate spans.# A365 Observability — best-effort instrumentation (verify against official sample)TaskCreate — "Add BaggageBuilder context to message handler"
> Skip this phase if BaggageMiddleware was registered in Phase 3 — the middleware handles
> baggage propagation automatically for every request.
> Auth mode note: All three authMode values use authHandlerName: "AGENTIC" in the
> code — the token exchange call is identical. The identity in traces is determined by Azure AD
> provisioning and the incoming token. Add an inline comment indicating which mode was chosen.
dotnet-observability.md (full code sample under "Agent Class — Message Handler (OBO Path)"):OBO path (obo / agentic-user) — applies to both AI Teammate agents and Standard .NET agents:
IExporterTokenCache<AgenticTokenStruct> in the constructor (auto-registered by the distro — no AddAgenticTracingExporter() call needed).IConfiguration (for blueprint/observability config) and ILogger<MyAgent>.Utility.ResolveAgentIdentity(context, authToken) for non-agentic turns (the SDK names the second parameter generically authToken — it accepts both OBO tokens and agentic-path tokens returned by UserAuthorization.GetTurnTokenAsync). Do NOT fall back to Guid.Empty.ToString() — that creates a synthetic identity the exporter cannot authenticate, polluting traces with "No token obtained. Skipping export for this identity." warnings. string? resolvedAgentId = null;
if (turnContext.Activity.IsAgenticRequest())
{
resolvedAgentId = turnContext.Activity.GetAgenticInstanceId();
}
else if (!string.IsNullOrEmpty(authHandlerName))
{
try
{
var authToken = await UserAuthorization
.GetTurnTokenAsync(turnContext, authHandlerName, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (!string.IsNullOrEmpty(authToken))
{
resolvedAgentId = Utility.ResolveAgentIdentity(turnContext, authToken);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not resolve agent id from auth token; A365 observability skipped for this turn.");
}
}
var resolvedTenantId = turnContext.Activity.Conversation?.TenantId
?? turnContext.Activity.Recipient?.TenantId;
var hasObservabilityIdentity = !string.IsNullOrEmpty(resolvedAgentId)
&& !string.IsNullOrEmpty(resolvedTenantId);
GetAgenticInstanceId() returns the agent's service principal object ID (the instance ID assigned by A365 for the Teams agentic identity). Utility.ResolveAgentIdentity(context, authToken) decodes the agent identity from a JWT — works for both OBO tokens and agentic-path tokens (SDK signature names the param generically authToken). Both paths produce the same kind of ID — what shows up in MAC Advanced Hunting.
hasObservabilityIdentity == true. Skip both calls cleanly when the identity can't be resolved: using IDisposable? baggageScope = hasObservabilityIdentity
? new BaggageBuilder()
.TenantId(resolvedTenantId!)
.AgentId(resolvedAgentId!)
.Build()
: null;
if (hasObservabilityIdentity)
{
try
{
_agentTokenCache.RegisterObservability(
resolvedAgentId!,
resolvedTenantId!,
new AgenticTokenStruct(
userAuthorization: UserAuthorization,
turnContext: turnContext,
authHandlerName: authHandlerName ?? string.Empty),
EnvironmentUtils.GetObservabilityAuthenticationScope());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register observability token.");
}
}
Note: Some SDK versions support object-initializer syntax instead. If the constructor form fails to compile, try property-initializer: new AgenticTokenStruct { UserAuthorization = ..., TurnContext = ..., AuthHandlerName = ... }.
authHandlerName should resolve to the agentic auth handler name (from config AgentApplication:AgenticAuthHandlerName) when IsAgenticRequest() is true, OBO handler name (from AgentApplication:OboAuthHandlerName) otherwise.Agent365Observability section in appsettings.json (EnableAgent365Exporter and base exporter settings are still required — Phase 6 handles these). For OBO, you do not need to hardcode per-agent IDs, tenant IDs, or S2S credentials in that section — the agent ID and tenant ID are resolved from the request at runtime on each turn.microsoft/Agent365-Samples). The older A365OtelWrapper.InvokeObservedAgentOperation(...) static-wrapper pattern at Agent365-samples/dotnet/agent-framework/sample-agent/telemetry/A365OtelWrapper.cs is functionally equivalent but uses a separate helper class.S2S path:
Agent365ObservabilityContext (singleton registered by AddAgent365Observability()) in the constructor — not IExporterTokenCache<AgenticTokenStruct>new BaggageBuilder().FromTurnContext(turnContext).Build() as a separate using var baggageScope — FromTurnContext() is an extension on BaggageBuilder only; it does not exist on InvokeAgentScope or any scope typeInvokeAgentScope.Start(new Request(...), new InvokeAgentScopeDetails(endpoint: new Uri("...")), _obs.AgentDetails, callerDetails) as a separate using var scope — InvokeAgentScopeDetails has no parameterless constructor; always pass at least endpoint. CallerDetails with the blueprint sponsor's identity is required for S2S traces to appear in the portalRegisterObservability() call; no .FromTurnContext() chaining on the scope// A365 auth mode: S2S — FMI 3-hop chain via ObservabilityTokenService (scope: api://9b975845-388f-4429-889e-eab1ef63949c/.default)Mark all new lines with: // A365 Observability — best-effort instrumentation (verify against official sample)
nodejs-observability.md:AgenticTokenCacheInstance from @microsoft/opentelemetry (single unified package).obo / agentic-user): Resolve agentId and tenantId dynamically from TurnContext each turn (never from config), then refresh the exporter token (non-fatal, wrap in try/catch): const agentId = turnContext.activity?.recipient?.agenticAppId ?? '';
const tenantId = turnContext.activity?.recipient?.tenantId ?? '';
await AgenticTokenCacheInstance.RefreshObservabilityToken(
agentId, tenantId, turnContext,
agentApplication.authorization, // ← the AgentApplication auth object, NOT an auth-handler name string
);
obo (signed-in user): agentApplication.authorization exchanges the token as the signed-in user → traces attributed to the userobo (agentic identity): agentApplication.authorization exchanges the token as the agentic user provisioned in Azure AD → traces attributed to the agentapi://9b975845-388f-4429-889e-eab1ef63949c/.default) — no need to import getObservabilityAuthenticationScope (removed in 1.0).preloadObservabilityToken(turnContext) helper function to keep the handler clean. See nodejs-observability.md for the full helper implementation.AgenticTokenCacheInstance.RefreshObservabilityToken — there is no user authorization token. The tokenResolver passed to useMicrosoftOpenTelemetry() (set up in Phase 3) handles authentication via the FMI 3-hop chain token service.BaggageBuilderUtils.fromTurnContext(new BaggageBuilder(), turnContext as any).build() and runs InvokeAgentScope.start(...) inside baggageScope.run(...). The configureA365Hosting(adapter, { enableBaggage: true }) middleware registered in Phase 3 is a fallback that auto-populates baggage outside the handler, but it does NOT cover the scopes you'll add in Phase 5.5 — those need the manual outer wrapping or they get filtered as Partitioned into 0 identity groups. In Phase 4 itself, just refresh the token; do NOT call InvokeAgentScope.start here.// A365 auth mode: {authMode} — see: https://learn.microsoft.com/en-us/entra/agent-id/agent-on-behalf-of-oauth-flow// A365 Observability — best-effort instrumentation (verify against official sample)host_agent_server.py — the helper lives in the HOST file, not the agent class. The verified AF sample places _setup_observability_token in host_agent_server.py:130-156 so it has access to the AgentApplication instance and can be called by activity middleware. Per-turn baggage construction also lives in the handler/middleware layer in host_agent_server.py, NOT in agent.py.host_agent_server.py (the host file) — Refresh the per-turn exporter token following the reference pattern in python-observability.md:microsoft-opentelemetry 1.1+ — do not import get_observability_authentication_scope unless you need to override the default. If overriding, pass via a365_observability_scope_override to use_microsoft_opentelemetry. The exchange_token() call below omits scopes= and lets the auth handler resolve the default.cache_agentic_token from token_cache (the custom module created in Phase 5) — or use AgenticTokenCache from the hosting helpers.obo / agentic-user): Resolve agent_id and tenant_id dynamically from context each turn (never from config), then exchange the OBO token (non-fatal, wrap in try/except): agent_id = context.activity.recipient.agentic_app_id
tenant_id = context.activity.recipient.tenant_id
await self._setup_observability_token(context, tenant_id, agent_id)
The _setup_observability_token helper exchanges and caches the token:
async def _setup_observability_token(self, context, tenant_id, agent_id):
exaau_token = await self.agent_app.auth.exchange_token(
context,
scopes=get_observability_authentication_scope(),
auth_handler_id=self.auth_handler_name # from config — NOT hardcoded "AGENTIC"
)
cache_agentic_token(tenant_id, agent_id, exaau_token.token)
auth_handler_name must come from config (e.g., AgentApplication:AgenticAuthHandlerName) — never hardcode "AGENTIC"; it is the registered auth handler name in your agent setup.agentic-user (AI Teammate): the exchange returns a token for the agent's own Agentic User identity → traces attribute to the agentobo (non-AI Teammate): the exchange returns whatever the configured auth handler resolves — typically the signed-in user, but it can also be the agent's own identity if the handler is configured that way_setup_observability_token — token comes from the background token service wired in Phase 3. The handler should NOT touch tokens.ObservabilityHostingManager.configure(adapter.middleware_set, ObservabilityHostingOptions(enable_baggage=True)) which auto-populates baggage from TurnContext for every request. (Optional fallback if you skipped that: build manually with populate(builder, context) then with builder.build():.)# A365 auth mode: {authMode} — see: https://learn.microsoft.com/en-us/entra/agent-id/agent-on-behalf-of-oauth-flow# A365 Observability — best-effort instrumentation (verify against official sample)TaskCreate — "Implement agentic token resolver with caching"
For AI Teammate agents and Standard agents on the OBO/agentic-user path, the built-in token cache handles caching automatically — no custom resolver needed. With the Microsoft.OpenTelemetry distro the cache is auto-registered by UseMicrosoftOpenTelemetry(...) (Phase 3). With the legacy individual packages it's registered explicitly via AddAgenticTracingExporter (.NET), AgenticTokenCacheInstance (Node.js), or AgenticTokenCache (Python). Skip to step 3 for these agents.
builder.UseMicrosoftOpenTelemetry(...) call (Phase 3) auto-registers IExporterTokenCache<AgenticTokenStruct> in DI — no separate AddAgenticTracingExporter() call is needed. If you're on the legacy two-package wiring, AddAgenticTracingExporter() provides the same DI instance.IExporterTokenCache<AgenticTokenStruct> in the constructor and call RegisterObservability(...) per turn (already done in Phase 4).The ObservabilityTokenService background service (created in Phase 3 via the scaffold) acquires and refreshes the Observability API token automatically via the FMI 3-hop chain (Blueprint → Agent Identity → Power Platform PFAT token) — no manual TokenResolver delegate needed.
Observability/ObservabilityServiceExtensions.cs and Observability/ObservabilityTokenService.cs exist. If yes, skip — they were already created in Phase 3.dotnet-observability.md. These files provide AddAgent365Observability() (DI extension registering AddServiceTracingExporter, ObservabilityTokenService, and Agent365ObservabilityContext) and ObservabilityTokenService (background service that acquires the Observability API token via the FMI 3-hop chain and refreshes it every 50 minutes).AgenticTokenCacheInstance from @microsoft/agents-a365-observability-hosting handles caching automatically. The useMicrosoftOpenTelemetry() call in Phase 3 wires it as the tokenResolver. No additional token resolver module is needed unless Use_Custom_Resolver=true is required (see reference doc for custom resolver pattern).
Check if observability/observability-token-service.ts exists. If yes, skip — it was created in Phase 3.
If absent (Phase 3 was skipped or re-running), create observability/token-cache.ts and observability/observability-token-service.ts now using the scaffold from nodejs-observability.md (S2S section). The token service uses MSAL (@azure/msal-node) with fmiPath to acquire tokens via the FMI 3-hop chain targeting scope api://9b975845-388f-4429-889e-eab1ef63949c/.default. Call startTokenService(config) at app startup and pass tokenResolver from the cache module to useMicrosoftOpenTelemetry().
The token_cache.py custom module (located at project root or observability/token_cache.py) provides cache_agentic_token and get_cached_agentic_token. The a365_token_resolver in use_microsoft_opentelemetry() (Phase 3) is wired to get_cached_agentic_token. The per-turn _setup_observability_token helper (Phase 4) calls cache_agentic_token after each OBO exchange. If token_cache.py is absent (e.g., this phase is reached before Phase 4 ran), create it now following the OBO token cache pattern in python-observability.md.
Check if observability/observability_token_service.py exists. If yes, skip — it was created in Phase 3.
If absent, create observability/token_cache.py and observability/observability_token_service.py now using the scaffold from python-observability.md (S2S section). The token service uses MSAL (msal.ConfidentialClientApplication) with fmi_path to acquire tokens via the FMI 3-hop chain targeting scope api://9b975845-388f-4429-889e-eab1ef63949c/.default. Call acquire_initial_token() for pre-warm, schedule run_token_service() as asyncio.create_task(), and pass token_cache.get_cached_token as the a365_token_resolver in use_microsoft_opentelemetry().
TaskUpdate — Mark complete.
TaskCreate — "Wire InvokeAgentScope, InferenceScope, ExecuteToolScope"
> Auto-instrumentation vs manual: Whether manual scopes are needed depends on authMode and
> whether auto-instrumentation framework extensions were installed in Phase 2.
>
> | Situation | InvokeAgentScope | InferenceScope | ExecuteToolScope |
> |---|---|---|---|
> | authMode = "s2s" | Required — add always | Required — add always | Required — add always |
> | OBO + framework extension installed (Phase 2) | Required — add always | Skip — auto-instrumentation generates these | Only for local/custom tools not covered by the extension |
> | OBO + no framework extension | Required — add always | Required — add always | Required — add always |
>
> "Autonomous" agents can run on either OBO or S2S — auth mode is the actual differentiator here, not whether the agent is autonomous.
>
> Rule: Never skip InvokeAgentScope — it wraps the turn and is always required for traces to
> appear in the MAC portal. Auto-instrumentation extensions cover LLM calls (InferenceScope) and
> framework-managed tool calls (ExecuteToolScope), but they do not wrap the agent turn itself.
Determine which scopes to add:
authMode = "s2s": proceed directly — add all three scopes without prompting (required for S2S agents).InvokeAgentScope always.InferenceScope — the framework extension instruments LLM calls automatically.ExecuteToolScope wrappers for those."* Add ExecuteToolScope only if the user confirms custom tool calls exist.> Store publishing: The Agent 365 store validator requires InvokeAgentScope, InferenceScope,
> and ExecuteToolScope to be present. For OBO agents with framework extensions, the extension
> satisfies InferenceScope and framework-managed ExecuteToolScope automatically.
Follow the reference patterns in dotnet-observability.md for each scope being added:
InvokeAgentScope — wrap the top-level message handler to capture agent invocation telemetry. Gate the scope on hasObservabilityIdentity (see Phase 4) so it's only opened when a real (agent, tenant) tuple is available; otherwise the scope groups spans under a synthetic identity the exporter cannot authenticate.InferenceScope — wrap each LLM call to capture model, token counts, finish reasons *(skip if framework extension installed)*ExecuteToolScope — wrap each local/custom tool call *(skip if framework extension covers all tool calls)*OutputScope — use for async response scenarios where output isn't captured synchronouslyCallerDetails must be passed to InvokeAgentScope.Start() as the 4th parameter — required for traces to appear in the MAC portal. For OBO/agentic-user, build it from turnContext.Activity.From (AadObjectId/Name). For S2S, read sponsor details from config (Agent365Observability:Sponsor section) and construct CallerDetails with UserDetails(userId, userName, userEmail).InvokeAgentScopeDetails.endpoint URI: build the endpoint from Agent365Observability:AgentBlueprintId (a GUID — always URI-safe) under the RFC 2606 reserved .invalid TLD. Do NOT slugify the free-form display name — characters like apostrophes, &, parentheses, or slashes throw UriFormatException at runtime: var blueprintForUri = obsConfig["AgentBlueprintId"];
var endpointUri = !string.IsNullOrEmpty(blueprintForUri)
? new Uri($"https://{blueprintForUri}.agent.invalid/")
: new Uri("https://agent.invalid/");
ChatClientAgentOptions.Id to match resolvedAgentId when constructing the ChatClientAgent for each turn. Without this, the AI SDK auto-generates a fresh N-format GUID (32 hex chars, no dashes) per turn, producing orphan identity groups the exporter cannot authenticate (logs show "Obtained token for agent <random32hex> tenant ..." followed by "No token obtained. Skipping export for this identity."). Pattern: var options = new ChatClientAgentOptions
{
Name = obsConfig["AgentName"] ?? "Agent",
ChatOptions = toolOptions,
ChatHistoryProvider = ...,
};
if (!string.IsNullOrEmpty(resolvedAgentId))
{
options.Id = resolvedAgentId;
}
UserDetails directly (not wrapped in CallerDetails) to InferenceScope.Start() and ExecuteToolScope.Start() as the optional 4th parameterAgent365ObservabilityContext singleton (S2S path) should hold both AgentDetails and CallerDetails propertiesThe pattern is per-stack — read agentStack from .a365-workspace-detection.local.json and branch:
LangChain or OpenAI → canonical wrapping pattern below (verified against the LangChain + OpenAI samples).Claude → InferenceScope-only, no outer baggageScope, no InvokeAgentScope. The Claude sample (Agent365-Samples/nodejs/claude/sample-agent/src/client.ts) wraps each LLM call individually in src/client.ts. Skip the canonical pattern below; follow the InferenceScope-only shape from the Claude sample instead. Tell the user: *"Claude SDK uses a different observability shape — wrapping per LLM call in client.ts instead of around the message handler. InvokeAgentScope is not used."*Semantic Kernel or Google ADK → handled by Phase 0.6 framework guard (soft-warn — auto-instrumentation may not patch the LLM library; manual InferenceScope.start wrapping required around each LLM call).For LangChain + OpenAI, follow the reference patterns in nodejs-observability.md for each scope. The wrapping order is non-negotiable — wrong order produces silent span drops (logged as Partitioned into 0 identity groups).
Canonical pattern (generate exactly this shape):
await preloadObservabilityToken(turnContext); // STEP 1 — refresh token (cold-turn fix)
const baggageScope = BaggageBuilderUtils // STEP 2 — outer baggage scope
.fromTurnContext(new BaggageBuilder(), turnContext as any)
.sessionDescription('agent-turn')
.build();
await baggageScope.run(async () => { // STEP 3 — scopes run INSIDE baggage
const scope = InvokeAgentScope.start(request, scopeDetails, agentDetails, callerDetails);
try {
await scope.withActiveSpanAsync(async () => {
// InferenceScope / ExecuteToolScope / agent invocation here
});
} finally { scope.dispose(); }
});
Why this exact shape:
preloadObservabilityToken before baggageScope.run, the first export attempt on a cold turn sees an empty token, retries until timeout, and the span is not exported.baggageScope.run wrapping InvokeAgentScope.start, the spans have no microsoft.tenant.id / gen_ai.agent.id baggage attached — the exporter filters them as Partitioned into 0 identity groups (N spans skipped) and they never reach MAC.Additional rules:
BaggageBuilder AND BaggageBuilderUtils from @microsoft/opentelemetry. Both are required.InvokeAgentScopeDetails is {} in Node.js — endpoint is optional and unused. Do NOT generate endpoint: new Uri(...) — that's the .NET API surface and will not compile in TypeScript.InferenceScope — wrap each LLM call *(skip if framework extension installed)*ExecuteToolScope — wrap each local/custom tool call *(skip if framework extension covers all tool calls)*OutputScope — for async scenariosCallerDetails must be passed to InvokeAgentScope.start() as the 4th parameter — required for traces to appear in the MAC portalagent365Observability__sponsorUserId, agent365Observability__sponsorUserName, agent365Observability__sponsorUserEmail) and construct the CallerDetails objectUserDetails directly to InferenceScope.start() and ExecuteToolScope.start() as the optional 4th parametercallerDetails (for InvokeAgentScope) and userDetails (for InferenceScope/ExecuteToolScope) from the entry point module alongside agentDetailsFollow the reference patterns in python-observability.md for each scope being added:
InvokeAgentScope — wrap the top-level message handler as a context managerInferenceScope — wrap each LLM call *(skip if framework extension installed)*ExecuteToolScope — wrap each local/custom tool call *(skip if framework extension covers all tool calls)*OutputScope — for async response scenariosCallerDetails / UserDetails must be supplied when creating the top-level InvokeAgentScope — required for traces to appear in the MAC portalCallerDetails(UserDetails(userId, userName, userEmail))UserDetails directly to InferenceScope, ExecuteToolScope, and OutputScope when their optional user parameter is availableagent_details and caller_details / user_details so nested scopes can reuse them consistentlyAll new lines marked with the language-appropriate comment:
// A365 Observability — best-effort instrumentation (verify against official sample)# A365 Observability — best-effort instrumentation (verify against official sample)TaskUpdate — Mark complete.
TaskCreate — "Update configuration files with observability settings"
Read the language-appropriate reference for the complete config block:
${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/dotnet-observability.md → "appsettings.json"${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/nodejs-observability.md → ".env"${CLAUDE_PLUGIN_ROOT}/skills/instrument-observability/references/python-observability.md → ".env"Apply these invariants across all three languages:
Agent365Observability (.NET) orENABLE_A365_OBSERVABILITY_EXPORTER (Node.js / Python) already exists, do not
overwrite. Add only missing keys.
Logging section. Read appsettings.json fullybefore writing. If Logging or Logging.LogLevel exists, merge the new
log-level keys (Microsoft.Agents.A365.Observability: Debug,
OpenTelemetry: Debug) into that block. A second Logging block produces
silently invalid config where only the last one wins.
EnableAgent365Exporter: true at the root. a365 setup may writefalse; this skill corrects it. Add an appsettings.Development.json with
"EnableAgent365Exporter": false so local dev traces go to console only.
Agent365Observability.Sponsor (UserId, UserName, UserEmail) in appsettings.json.agent365Observability__sponsorUserId / __sponsorUserName / __sponsorUserEmail in .env.ClientId, ClientSecret, and UseManagedIdentity: false (forlocal dev — MSI fails off-Azure with CredentialUnavailableError) under
Agent365Observability.
useS2SEndpoint: true (Node) / a365_use_s2s_endpoint=True(Python) is set in code in Phase 3 — no env var equivalent in 1.0+. The
legacy AGENT365_USE_S2S_ENDPOINT env var is ignored.
AgentBlueprintId / TenantId are empty → "run a365 setup to populate".false (Node.js / Python local dev) → "instrumented butdisabled; set ENABLE_A365_OBSERVABILITY_EXPORTER=true to start exporting".
.env (Node.js / Python) — OTEL_LOG_LEVEL=INFO (OpenTelemetry SDK's own internal logger) and A365_OBSERVABILITY_LOG_LEVEL=info|warn|error (pipe-separated levels emitted by the A365 exporter). For .NET, write the equivalent Logging.LogLevel.Microsoft.Agents.A365.Observability: Information to appsettings.json AND set OTEL_LOG_LEVEL=INFO / A365_OBSERVABILITY_LOG_LEVEL=info|warn|error as env vars (.NET reads both forms). Recommended baseline: INFO + info|warn|error in prod; users can trim to WARN + warn|error to reduce noise. Write them as a labeled # ── Observability verbose logging ── block so the two vars stay grouped. Additive — never overwrite values the user has set.If the project also uses .env.example (Node.js / Python), update it with
placeholder values to match .env.
TaskUpdate — Mark complete.
TaskCreate — "Validate build passes"
dotnet build
npm install # Ensure new packages are installed
npm run build || npm run compile || echo "No build script found — skipping compile check"
python3 -c "from microsoft.opentelemetry import use_microsoft_opentelemetry; from microsoft.opentelemetry.a365.hosting import ObservabilityHostingManager; print('A365 observability imports OK')" 2>/dev/null || python -c "from microsoft.opentelemetry import use_microsoft_opentelemetry; from microsoft.opentelemetry.a365.hosting import ObservabilityHostingManager; print('A365 observability imports OK')"
pip install).TaskCreate — "Test locally"
Ask the user:
AskUserQuestion:
question: "Build succeeded. Want to run a quick local test now?"
options:
- "Yes — run the test-local skill"
- "No — I'll test later"
If yes, invoke the test-local skill.
TaskUpdate — Mark complete.
TaskCreate — "Verify a span actually exports"
Without this phase the skill ends "instrumented successfully" but the user has no way to know whether spans actually reach MAC until 15-90 min later when indexing catches up. This phase runs the agent for ~30 seconds with verbose-logging env vars enabled, sends one message, and greps the log for the specific line that confirms export succeeded. Pass/fail is visible immediately.
Node.js:
OTEL_LOG_LEVEL=INFO A365_OBSERVABILITY_LOG_LEVEL=info|warn|error npm start > .a365-smoketest.log 2>&1 &
Use Claude Code's run_in_background: true so the agent stays up while we probe.
/api/messages (or instruct the user to send one via AgentsPlayground / Teams). grep -E "export-group succeeded|exported successfully|rejectedSpans:0" .a365-smoketest.log | head -5
admin.cloud.microsoft → Advanced Hunting → CloudAppEvents filtered by AgentId = <AUID> after that delay."*Partitioned into 0 identity groups lines → ❌ silent drop. Most likely: missing outer baggage scope (Phase 5.5) OR missing exporter flag (a365.enableObservabilityExporter: true / env var). Re-check Phases 3 + 5.5.Python: same flow with OTEL_LOG_LEVEL + A365_OBSERVABILITY_LOG_LEVEL env vars and python host_agent_server.py instead of npm start.
.NET: skip this phase — the .NET CLI's own boot-time logging covers the verification path. If the user wants stricter checking, set Logging.LogLevel.Microsoft.Agents.A365.Observability: Information in appsettings.json and grep dotnet run output for "Sending N spans to ..." / "HTTP 202 exporting spans".
TaskUpdate — Mark complete with the result (pass / fail / skipped).
✅ A365 observability instrumented successfully!
**Agent type:** [.NET AgentFramework | Node.js | Python]
**Agent kind:** [AI Teammate | Agent (Non AI Teammate)]
**Auth mode:** [Access data as signed-in user | Its own persistent identity | Runs autonomously]
**Packages installed:** [list packages]
**Files modified:** [list files]
**Next steps:**
1. Enable exporting when ready for production:
- .NET: set EnableAgent365Exporter: true in appsettings.json
- Node.js / Python: set ENABLE_A365_OBSERVABILITY_EXPORTER=true in .env (or `a365.enableObservabilityExporter: true` in code — both required alongside `a365.enabled: true`)
2. Run your agent and verify traces appear in the Observability dashboard.
**What to expect for MAC visibility (first-run reality check):**
- **Indexing lag: 15–90 minutes** between first successful export and spans appearing in `admin.cloud.microsoft → Advanced Hunting → CloudAppEvents`. If you query immediately after instrumenting, you'll see empty results — that's not a bug.
- **Instance approval required.** Spans only attribute to a `CloudAppEvents` row when the AI Teammate's agent instance has been approved at `admin.cloud.microsoft/#/agents/all/requested` and an Agentic User UPN has been issued. Without that, exported spans land but don't surface in MAC queries.
- **KQL filter MUST use the AUID, NOT the blueprint id.** The `AgentId` column in `CloudAppEvents` is the runtime AUID resolved from `turnContext.activity.recipient.agenticAppId`. The `agent365Observability__agentId` env var that `a365 setup all` stamps into `.env` is the BLUEPRINT id — filtering by that value returns empty results. Get the AUID from your agent logs (the exporter logs `Obtained token for agent <AUID> tenant ...`) or from `recipient.agenticAppId` in any inbound activity.
**Verbose logging — only enable when actively debugging:**
- Node.js / Python: uncomment `OTEL_LOG_LEVEL=INFO` AND `A365_OBSERVABILITY_LOG_LEVEL=info|warn|error` in `.env`. **Both** are required to see `[Agent365Exporter]` activity — the exporter uses a wrapped logger that defaults to silent.
- Grep for `exported successfully` / `export-group succeeded` to confirm spans are flowing; `Partitioned into 0 identity groups (N spans skipped)` for spans outside an active baggage scope is **expected** (early framework / middleware / health-ping spans) — not an error.
3. [If authMode = obo] Confirm the OBO token exchange is working correctly.
- Signed-in user sub-type: verify the signed-in user's token is passed correctly.
→ OBO flow docs: https://learn.microsoft.com/en-us/entra/agent-id/agent-on-behalf-of-oauth-flow
- Agentic identity sub-type: ensure the agentic user identity has been provisioned in Azure AD.
→ Identity docs: https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity
4. [If authMode = agentic-user] Confirm the agentic-user M365 license and identity are provisioned.
→ Identity docs: https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity
5. [If authMode = s2s] No user token required — verify agent blueprint credentials are configured.
→ Auth flow docs: https://learn.microsoft.com/en-us/microsoft-agent-365/developer/authentication-flow
All instrumented lines are marked with:
// A365 Observability — best-effort instrumentation (verify against official sample)
If the agent type cannot be determined:
.a365setup-unknown-agentIf the build fails after instrumentation:
If expected files are not found:
This skill is safe to rerun. On subsequent runs:
a365 setup all automatically grants Agent365.Observability.OtelWrite to the Agent Identity SP (both delegated and application) for all newly provisioned agents. No Global Administrator is required for agents set up with this CLI version.
Take microsoft/instrument-observability from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, npm.
Without those the skill loads but fails at the first command.