diff --git a/.gitignore b/.gitignore index ab02d105..27ea5c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,11 @@ a365.generated.config.json app.zip publish/ +# Salesforce / SFDX local artifacts +**/.sf/ +**/.sfdx/ +**/.localdevserver/ + # OS-specific files .DS_Store Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md index a62d538b..4c58470c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,8 @@ Agent365-Samples/ │ ├── n8n/ │ ├── perplexity/ │ └── vercel-sdk/ +├── salesforce/ # Salesforce/Apex samples (SFDX projects) +│ └── apex-observability/ # Native Apex Agent 365 observability + tool boundary ├── docs/ # Repository-wide documentation │ └── design.md # Architectural patterns ├── prompts/ # AI development prompts @@ -74,6 +76,28 @@ python start_with_generic_host.py Python samples use `pyproject.toml` for dependency management. Most samples support `uv` for faster dependency resolution. +### Salesforce / Apex + +Salesforce samples are SFDX projects (no hosted process). Use the Salesforce CLI (`sf`). + +**Validate (no persist):** +```bash +cd salesforce/ +sf project deploy start --source-dir force-app/main/default --target-org --dry-run --test-level RunLocalTests +``` + +**Deploy:** +```bash +sf project deploy start --source-dir force-app/main/default --target-org --test-level RunLocalTests +``` + +**Test:** +```bash +sf apex run test --target-org --test-level RunLocalTests --wait 10 +``` + +`RunLocalTests` enforces 75% aggregate org coverage. Full Apex tests require a Dev Hub + scratch org. + ### Node.js / TypeScript **Setup:** @@ -190,6 +214,12 @@ All source files MUST have Microsoft copyright headers: // Licensed under the MIT License. ``` +**Apex (`.cls`):** +```apex +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +``` + **Exclusions**: Auto-generated files, test files, configuration files (`.json`, `.yaml`, `.md`), and third-party code. ### Legacy Reference Check diff --git a/README.md b/README.md index c2673d80..2270fefe 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This repository contains sample agents and prompts for building with the Microsoft Agent 365 SDK. The Microsoft Agent 365 SDK extends the Microsoft 365 Agents SDK with enterprise-grade capabilities for building sophisticated agents. It provides comprehensive tooling for observability, notifications, runtime utilities, and development tools that help developers create production-ready agents for platforms including M365, Teams, Copilot Studio, and Webchat. -- **Sample agents** are available in C# (.NET), Python, and Node.js/TypeScript +- **Sample agents** are available in C# (.NET), Python, Node.js/TypeScript, and Salesforce/Apex - **Prompts** to help you get started with AI-powered development tools like Cursor IDE ## SDK Versions @@ -23,7 +23,7 @@ Please help improve the Microsoft Agent 365 SDK and CLI by taking our survey: [A ## Current Repository State This samples repository is currently in active development and contains: -- **Sample Agents**: Production-ready examples in C#/.NET, Python, and Node.js/TypeScript demonstrating observability, notifications, tooling, and hosting patterns +- **Sample Agents**: Production-ready examples in C#/.NET, Python, Node.js/TypeScript, and Salesforce/Apex demonstrating observability, notifications, tooling, and hosting patterns - **Prompts**: Guides for using AI-powered development tools (e.g., Cursor IDE) to accelerate agent development ## Documentation diff --git a/salesforce/apex-observability/.forceignore b/salesforce/apex-observability/.forceignore new file mode 100644 index 00000000..cfa7ff0e --- /dev/null +++ b/salesforce/apex-observability/.forceignore @@ -0,0 +1,10 @@ +# Custom Metadata RECORDS can fail to deploy via the Metadata API / sf CLI on some orgs +# (they return an opaque UNKNOWN_EXCEPTION). The A365_Observability_Config.Default record +# is instead created/updated at runtime via the Apex Metadata API +# (Metadata.Operations.enqueueDeployment — see scripts/create-obs-config.apex). The template +# file is kept in git for documentation and repeatability, but excluded from CLI deploys so +# a full `sf project deploy` of force-app does not fail on it. +# +# NOTE: the Custom Metadata TYPE + fields (objects/A365_Observability_Config__mdt) DO deploy +# normally and are intentionally NOT ignored. +force-app/main/default/customMetadata/** diff --git a/salesforce/apex-observability/.gitignore b/salesforce/apex-observability/.gitignore new file mode 100644 index 00000000..35a37230 --- /dev/null +++ b/salesforce/apex-observability/.gitignore @@ -0,0 +1,12 @@ +# Salesforce / SFDX local artifacts +**/.sf/ +**/.sfdx/ +**/.localdevserver/ +.sfdx +.sf + +# Logs +*.log + +# OS / editor +.DS_Store diff --git a/salesforce/apex-observability/README.md b/salesforce/apex-observability/README.md new file mode 100644 index 00000000..73f6ee08 --- /dev/null +++ b/salesforce/apex-observability/README.md @@ -0,0 +1,258 @@ +# Salesforce Apex — Native Agent 365 Observability + +This sample shows how **Salesforce Apex** can participate in [Microsoft Agent 365](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +observability as a **first-class telemetry emitter**. When an Agent 365 agent calls into Salesforce +(or an Agentforce agent runs a Salesforce action), the Apex code emits its **own** Agent 365 OTLP +spans — correlated to the agent turn by a shared W3C trace id — so Salesforce proves it ran from +*its own* telemetry, not just the agent's `execute_tool` span. + +Because MSAL is unavailable in Apex, the sample hand-rolls the **S2S OAuth FMI 3-hop** that mints an +**agent-bound** Observability token directly in Apex, then POSTs spans to the Agent 365 OTLP ingest. +All emission is **fail-open**, **async**, and **config-gated**, so telemetry never affects the +business response. + +For comprehensive documentation, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## What This Sample Demonstrates + +| Pattern | Where | +|---------|-------| +| Apex exposed as an Agent 365 tool surface (REST) | `classes/A365ToolRest.cls` | +| Apex `@InvocableMethod` action for the Agentforce path | `classes/A365AgentforceTool.cls` | +| Outbound Apex → Agent 365 callout (correlated CLIENT span) | `classes/A365Callout.cls` | +| Native Apex OTLP span emission (fail-open, async, config-gated) | `classes/A365Telemetry.cls`, `classes/A365TelemetryQueueable.cls` | +| Hand-rolled FMI 3-hop **agent-bound** token in Apex (no MSAL) | `classes/A365ObsToken.cls` | +| OTLP body builder mirroring the Agent 365 exporter wire shape | `classes/A365ObsSpan.cls` | +| Salesforce **originating** a trace (Agentforce-native) | `classes/A365Telemetry.cls` (`originate*`), `classes/A365Trace.cls` | +| Secret-free credential metadata (External / Named Credentials) | `externalCredentials/`, `namedCredentials/` | +| Non-secret runtime config via Custom Metadata | `objects/A365_Observability_Config__mdt/`, `customMetadata/` | + +## How It Works + +Two complementary flows, one shared trace id: + +``` +Agent turn ──POST /services/apexrest/a365tool, traceparent: 00-T-S-01──▶ A365ToolRest.doPost + ├─ reply synchronously (fast, unchanged) + └─ A365Telemetry.emitToolSpan(...) (fail-open) + └─ enqueue A365TelemetryQueueable (async) + ├─ A365ObsToken.getToken() → FMI 3-hop, agent-bound token + ├─ A365ObsSpan.buildBody(...) → OTLP body (traceId=T, parent=S) + └─ POST callout:A365_Obs_Ingest → ingest 200 +``` + +Design rules (all enforced in code): + +- **Fail-open** — telemetry never breaks the business response. Any error is swallowed (debug-logged only). +- **Async** — the span POST runs in a `Queueable` *after* the synchronous reply, so latency is unchanged. +- **Config-gated** — `A365_Observability_Config__mdt.Enabled__c = false` is a no-op kill switch. +- **Never fabricate a trace** — no inbound `traceparent` ⇒ no span. The Apex span reuses the agent + turn's `traceId` and nests under its `execute_tool` span (`parentSpanId`). (The Agentforce + *origination* path is the deliberate exception — it derives a deterministic trace id from the + session id; see [`agent/README.md`](agent/README.md).) + +## Classes + +| Class | Role | +| --- | --- | +| **`A365ToolRest`** | Apex REST endpoint `POST /services/apexrest/a365tool`. Reads `traceparent` from the HTTP header, replies, then emits a **SERVER** span (`gen_ai.tool.type = salesforce-apex`). | +| **`A365AgentforceTool`** | `@InvocableMethod` action for the Agentforce path. **Originates** an Agent 365 trace (root `invoke_agent` + `execute_tool`) seeded from the session id. | +| **`A365Callout`** | Outbound Apex → Agent 365 `/callback` callout (optional; set the `A365_Callback` endpoint). Emits a **CLIENT** span correlated to the same trace. | +| **`A365Telemetry`** | Public façade — the only entry point business code calls. Gates on config, requires a traceparent (boundary path), fail-open, enqueues the worker. | +| **`A365TelemetryQueueable`** | Async worker (`Queueable, Database.AllowsCallouts`). Acquires the token, builds the body, POSTs the span. | +| **`A365ObsToken`** | Mints the **agent-bound** Observability token via the FMI 3-hop (see Auth below). Caches it (per-transaction static + Platform Cache). | +| **`A365ObsSpan`** | Pure OTLP-body builder mirroring the Agent 365 exporter wire shape (flat-map attributes, string `kind`/`status`). No callouts → trivially unit-testable. | +| **`A365ObsConfig`** | Thin reader over the `A365_Observability_Config__mdt` `Default` record. **Never** holds secrets. | +| **`A365Trace`** | W3C trace-context helpers: `parseTraceparent(header)`, `newSpanId()`, deterministic `*FromSeed`. | +| `*Test` | Unit tests (`HttpCalloutMock` for token + ingest): body shape, trace reuse, parent linkage, no-op when disabled, fail-open. | + +## Authentication + Identity + +| Aspect | Model | +|--------|-------| +| **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 — +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. + +## Prerequisites + +- [Salesforce CLI (`sf`)](https://developer.salesforce.com/tools/salesforcecli) and a Salesforce org + (a [scratch org](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_scratch_orgs.htm) + via a Dev Hub, or a Developer Edition org) +- An Entra tenant onboarded to Agent 365, with at minimum the **Agent ID Developer** role +- An Agent 365 **agent** and its **blueprint** app registration (the blueprint sponsors the FMI chain). + See the [Agent 365 CLI](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-365-cli) + (`a365 setup all`) for provisioning the blueprint + agent identity. + +## Project Layout + +``` +apex-observability/ +├── force-app/main/default/ # deployable metadata (Apex, config, credentials, permission set) +├── scripts/ # Execute-Anonymous helpers (seed config, verify a span) +├── agent/ # OPTIONAL Agentforce agent (reference + per-org build steps) +├── sfdx-project.json +└── .forceignore +``` + +## Deploy + +```bash +cd salesforce/apex-observability +sf project deploy start --source-dir force-app/main/default --target-org --test-level RunLocalTests +``` + +> **Custom Metadata record caveat:** the CMDT *type + fields* deploy normally, but the `Default` +> **record** can fail to deploy via the CLI on some orgs (opaque `UNKNOWN_EXCEPTION`). It is excluded +> by `.forceignore` and seeded at runtime instead (next section). + +After deploying: + +1. **Seed the config record.** Edit the `<<...>>` placeholders in + [`scripts/create-obs-config.apex`](scripts/create-obs-config.apex), then: + + ```bash + sf apex run --file scripts/create-obs-config.apex --target-org + ``` + +2. **Enter the blueprint secret (Setup only, never git).** + `Setup → Named Credentials → External Credentials → "A365 Obs Entra" → Principals → BlueprintPrincipal`, + add a custom Authentication Parameter: + + ``` + Name = BlueprintBasicAuth + Value = base64(":") + ``` + + Compute the value (strip any trailing CR): + + ```bash + printf '%s' ':' | base64 -w0 + ``` + +3. **Assign the permission set** to the running user (REST integration user and/or Agentforce agent + running user): + + ```bash + sf org assign permset --name A365_Observability --target-org + ``` + +4. **(Optional) Set the callback endpoint** — only needed to exercise the outbound `A365Callout` path + (CLIENT span). Point the `A365_Callback` Named Credential at a public HTTPS URL that forwards to your + agent's `/callback` (e.g. a dev tunnel): `Setup → Named Credentials → "A365 Callback" → edit URL`. + Don't commit your live tunnel URL. The inbound boundary (`A365ToolRest`) and ingest paths work without it. + +## Configuration + +Non-secret runtime config lives in the `A365_Observability_Config__mdt.Default` record (seeded by the +script above). Secrets are **never** here — only in the External Credential entered in Setup. + +| Field | Default | Description | +|-------|---------|-------------| +| `Enabled__c` | `true` | Master kill switch for the boundary emitter (`false` = no-op). | +| `TenantId__c` | `<>` | Entra tenant id. | +| `AgentId__c` | `<>` | Agent 365 agent id (in the ingest URL; token `azp` must match). | +| `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. | +| `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/`). | + +## Testing + +### Unit tests + +```bash +sf apex run test --target-org --test-level RunLocalTests --wait 10 +``` + +The test suite uses `HttpCalloutMock` for the token + ingest hops and asserts the OTLP body shape, +trace reuse, parent linkage, the disabled no-op, and fail-open behavior. + +### Live span smoke test + +After deploy + secret entry + permission set: + +```bash +sf apex run --file scripts/verify-span.apex --target-org +sf apex tail log --target-org +``` + +The async worker logs the ingest HTTP status. On success, expect an INFO line like +`A365Telemetry ingest 200 spanId=<…> traceId=<…> corr=<…>`. On a non-2xx it logs the +status, `x-ms-correlation-id`, and the response body (which includes `rejectedSpans`). + +### End-to-end (with an agent) + +Point an Agent 365 agent's Salesforce tool at `POST /services/apexrest/a365tool`. A single agent turn +then produces one trace carrying both the agent's `execute_tool` span and the Apex SERVER span, +correlated by the shared `traceId`. + +## Optional: Agentforce origination + +To have **Salesforce originate** a trace from an Agentforce turn, build the optional Agentforce agent +that calls the `A365AgentforceTool` action and set `OriginateEnabled__c = true`. The agent is +org-specific, so it ships as a documented **reference** — see [`agent/README.md`](agent/README.md). + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| No span ingested, no error | Telemetry is fail-open — check the debug log (`sf apex tail log`) for a swallowed warning. | +| Token hop returns `401 AADSTS7002134` | `AgentId__c` and the External Credential's blueprint Basic value are not a matched blueprint→agent pair. | +| `You don't have read permissions on the User External Credential object` | The running user is missing the `A365_Observability` permission set (it grants `UserExternalCredential` read + the EC principal). | +| Ingest `403` | The token is not agent-bound (`azp` ≠ `AgentId__c`), or the tenant is not onboarded to Agent 365. | +| Nothing emitted at all | `Enabled__c = false`, or no inbound `traceparent` (the boundary path never fabricates a trace). | + +## Observability + +The boundary path (`A365ToolRest`, `A365Callout`) **reuses** the inbound agent trace and nests Apex +spans under the agent's `execute_tool` span. The origination path (`A365AgentforceTool`) **creates** +a trace deterministically from the session id, so an Agentforce turn surfaces as a first-class agent +trace. `A365ObsSpan` emits the Agent 365 exporter wire shape exactly — see +[OTLP wire shape](docs/design.md#otlp-wire-shape) for the field-level contract. + +For details on the observability SDK and instrumentation patterns, see the +[Agent observability guide](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability). + +## Support + +- **Issues**: Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-Samples/issues) section +- **Documentation**: See the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- **Security**: For security issues, please see [SECURITY.md](../../SECURITY.md) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide a +CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Additional Resources + +- [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- [Agent observability guide](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/observability) +- [Design notes](docs/design.md) + +## Trademarks + +*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License — see the [LICENSE](../../LICENSE.md) file for details. diff --git a/salesforce/apex-observability/agent/A365_Observability_Sample.agent b/salesforce/apex-observability/agent/A365_Observability_Sample.agent new file mode 100644 index 00000000..166e2df9 --- /dev/null +++ b/salesforce/apex-observability/agent/A365_Observability_Sample.agent @@ -0,0 +1,75 @@ +system: + instructions: | + You are an Agent 365 telemetry sample agent. For EVERY user message, without any + exception, you MUST call the A365 Echo action and return its Reply verbatim. Never + answer from general knowledge. Never treat a message as small talk — even greetings, + tests, and one-word inputs must go through the action. + messages: + welcome: "Ready. Send any message and I'll echo it through the A365 Echo action while emitting Agent 365 telemetry." + error: "Sorry, it looks like something has gone wrong." + +config: + developer_name: "A365_Observability_Sample" + # Set this to the running user the agent executes as in YOUR org (the user that holds the + # A365_Observability permission set). It is org-specific, so it is left as a placeholder. + default_agent_user: "<>" + agent_label: "A365 Observability Sample" + description: "Echoes every user message via the A365AgentforceTool Apex action and emits Agent 365 telemetry." + +language: + default_locale: "en_US" + additional_locales: "" + all_additional_locales: False + +variables: + RoutableId: linked string + source: @MessagingSession.Id + description: "The messaging session id, used as the trace seed for the conversation." + +knowledge: + rag_feature_config_id: "" + citations_url: "" + citations_enabled: False + + +start_agent Echo: + label: "Echo" + description: | + Handles every user message. Always calls the A365 Echo Apex action to echo the + message and emit Agent 365 telemetry. Route ALL inputs here — greetings, tests, + echoes, pings, and any other message. + reasoning: + instructions: -> + |For EVERY user message, immediately call the A365 Echo action (APEX). + |Pass the user's full message text as `prompt`. + |Pass the conversation id as `sessionId` from the RoutableId variable. + |Do NOT answer from general knowledge. Do NOT skip the action for greetings or small talk. + |Return the action's Reply text verbatim as your response. + actions: + APEX: @actions.APEX + with prompt = ... + with sessionId = @variables.RoutableId + actions: + APEX: + label: "APEX" + description: | + Echoes the prompt and emits Agent 365 telemetry for this turn. + target: "apex://A365AgentforceTool" + inputs: + prompt: string + label: "Prompt" + description: "Text sent from the Agentforce turn" + is_required: True + complex_data_type_name: "lightning__textType" + sessionId: string + label: "SessionId" + description: "Agentforce conversation/session id, if the agent maps it into the action input. Used as the trace seed so every span of the conversation shares one trace." + is_required: False + complex_data_type_name: "lightning__textType" + outputs: + reply: string + label: "Reply" + description: "Text returned to the Agentforce turn" + complex_data_type_name: "lightning__textType" + is_displayable: False + filter_from_agent: False diff --git a/salesforce/apex-observability/agent/README.md b/salesforce/apex-observability/agent/README.md new file mode 100644 index 00000000..fcb4545e --- /dev/null +++ b/salesforce/apex-observability/agent/README.md @@ -0,0 +1,44 @@ +# Agentforce agent (reference) + +This folder is a **reference** for the optional Agentforce agent that drives the *origination* +path of this sample — i.e. an Agentforce turn that calls the `A365AgentforceTool` Apex action, +which **originates** an Agent 365 trace (an `invoke_agent` root + an `execute_tool` span) from +Salesforce. + +> The Apex classes, configuration, named/external credentials, and permission set in +> [`../force-app`](../force-app) are the reusable core and deploy normally. The Agentforce agent +> itself is **org-specific** (its generated metadata embeds org record IDs and a running user), +> so it is **not** shipped as deployable metadata. Build it per-org using the steps below; this +> file documents exactly what to create. + +## What's here + +- **`A365_Observability_Sample.agent`** — the agent definition in human-readable **Agent Script** + (ASL). It shows the system instructions, the single `Echo` topic, and the wiring of the `APEX` + action to `apex://A365AgentforceTool` (passing `prompt` and `sessionId = @variables.RoutableId`). + +## Build it in your org + +1. Deploy the core first (see the [sample README](../README.md)) so `A365AgentforceTool` and the + `A365_Observability` permission set exist. +2. In **Setup → Agentforce Studio → Agentforce Agents**, create a new agent (or import the Agent + Script above via the Agent Builder / Metadata API). +3. Wire a single topic whose only action targets the Apex `A365AgentforceTool` invocable + (label **A365 Echo**), mapping: + - `prompt` ← the user's message text + - `sessionId` ← the `RoutableId` variable (`@MessagingSession.Id`) +4. Set the agent's **running user** to a user that has the **A365_Observability** permission set + assigned (this is the `default_agent_user` placeholder in the Agent Script). The running user + also needs **Read** on `UserExternalCredential` and access to the `A365_Obs_Entra` external + credential principal — both are granted by the permission set. +5. Activate the agent and send a message. With `OriginateEnabled__c = true` in the + `A365_Observability_Config.Default` record, the turn originates a Salesforce-authored trace + (`service.name = salesforce-agentforce`). + +## Why it's a reference, not deployable metadata + +Agentforce planner bundles (`genAiPlannerBundle`), bot definitions, and their local-action schemas +are **auto-generated** and carry org-scoped identifiers (e.g. component developer names suffixed +with org record IDs, and a concrete running-user). Shipping that generated metadata would neither +deploy cleanly to a different org nor be appropriate for a public sample. Building from the Agent +Script above regenerates clean, org-correct metadata for your org. diff --git a/salesforce/apex-observability/docs/design.md b/salesforce/apex-observability/docs/design.md new file mode 100644 index 00000000..dd879400 --- /dev/null +++ b/salesforce/apex-observability/docs/design.md @@ -0,0 +1,95 @@ +# Design — Native Apex Agent 365 Observability + +This document describes the architecture of the Salesforce Apex observability sample: how Apex emits +its own Agent 365 OTLP spans, the token model, the OTLP wire shape, and the module dependency graph. + +## Goal + +Make the Salesforce **Apex** layer a first-class Agent 365 telemetry emitter. When an agent turn +calls into Salesforce, Apex emits its **own** span correlated — by a shared W3C trace id — to the +agent turn that invoked it, rather than being visible only through the agent's `execute_tool` span. + +## Two flows, one trace id + +1. **Boundary emission (reuse the inbound trace).** `A365ToolRest` (REST) and `A365Callout` + (outbound) read the inbound `traceparent`, reply/act, then emit a span that **reuses** the inbound + `traceId` and nests under the agent's `execute_tool` span via `parentSpanId`. With no inbound + `traceparent`, no span is emitted — a trace is **never** fabricated on this path. + +2. **Origination (create the trace).** `A365AgentforceTool` runs inside an Agentforce turn where + there is no inbound `traceparent`. It derives a deterministic `traceId` and root span id from the + session id (`A365Trace.traceIdFromSeed` / `spanIdFromSeed`), so every span of a conversation + shares one trace and nests under one reconstructable `invoke_agent` root — even across independent + Apex transactions. The root is deduped (per-transaction static + Platform Cache). + +## Components + +``` +A365ToolRest ─┐ ┌─ A365ObsConfig (CMDT reader; non-secret config) +A365Callout ─┼─▶ A365Telemetry ──────▶ ┼─ A365Trace (W3C helpers + deterministic seeds) +A365Agentforce┘ (façade: gate, └─ A365ObsSpan (pure OTLP body builder) +Tool fail-open, async) + │ + ▼ + A365TelemetryQueueable ──▶ A365ObsToken (FMI 3-hop, agent-bound, cached) + (async worker, callouts)──▶ callout:A365_Obs_Ingest (OTLP POST) +``` + +Dependency direction (no cycles): +`A365ToolRest`/`A365Callout`/`A365AgentforceTool` → `A365Telemetry` → (`A365ObsConfig`, `A365Trace`, +`A365ObsSpan`); `A365TelemetryQueueable` → (`A365ObsToken`, `A365ObsSpan`). + +## Design rules + +- **Fail-open** — every public emit path is wrapped so any error is swallowed (debug-logged only) and + never affects the business response. +- **Async** — the span POST runs in a `Queueable` after the synchronous reply. Span data is captured + in the worker's constructor because the async context loses the REST request. +- **Config-gated** — `A365_Observability_Config__mdt.Enabled__c` (boundary) and `OriginateEnabled__c` + (origination) are independent kill switches; absent config fails safe (disabled). +- **Secret-free metadata** — the only secret (the blueprint client secret) lives in the + `A365_Obs_Entra` External Credential, entered in Setup. No secret appears in Apex or git. + +## Token model (FMI 3-hop, agent-bound) + +> **Background:** this uses Microsoft Entra **Agent ID** — an agent identity blueprint plus Federated +> Managed Identity (FMI) and the `jwt-bearer` grant. For the identity model and OAuth grant types, see +> [Microsoft Entra Agent ID — Agent OAuth flows](https://learn.microsoft.com/en-us/entra/agent-id/agent-on-behalf-of-oauth-flow). +> 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: + +1. **Hop 1/2** — as the blueprint app: `client_credentials` + `scope=api://AzureADTokenExchange/.default` + + `fmi_path=`, 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=` + + `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` + + `client_assertion=T1` + `scope=/.default`. Yields the agent-bound token (`azp=agentId`, + `roles=[Agent365.Observability.OtelWrite]`). (`A365_Obs_TokenJwt` Named Credential.) +3. **Ingest** — `POST {ingestBase}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1` + with `Authorization: Bearer `. (`A365_Obs_Ingest` Named Credential.) + +The token is cached (per-transaction static + Platform Cache `A365Obs` partition; TTL from the JWT +`exp` minus a safety skew) so a high-frequency emit path does not repeat the hops per span. + +## OTLP wire shape + +`A365ObsSpan` mirrors the live Agent 365 exporter rather than hand-rolling standard OTLP: + +- `attributes` is a flat object **map** (not an array of key/value). +- `kind` and `status.code` are enum **strings**; `*UnixNano` are **numbers**. +- resource key `microsoft.tenant.id`; request header `x-ms-tenant-id`. +- correlation keys: `traceId` (reused or seeded), `parentSpanId` (the agent's `execute_tool` span on + the boundary path), and `gen_ai.operation.name = execute_tool`. + +Because `A365ObsSpan` performs no callouts, it is unit-tested directly for body shape, attribute +flattening, trace reuse, and parent linkage. + +## Configuration surface + +`A365_Observability_Config__mdt.Default` carries non-secret runtime config (enable flags, tenant/agent +ids, endpoints, scopes, service names). `A365ObsConfig` reads it with safe built-in defaults so a +fresh org degrades gracefully. The record is seeded at runtime (`scripts/create-obs-config.apex`) +because CustomMetadata *records* can fail to deploy via the CLI on some orgs. diff --git a/salesforce/apex-observability/force-app/main/default/cachePartitions/A365Obs.cachePartition-meta.xml b/salesforce/apex-observability/force-app/main/default/cachePartitions/A365Obs.cachePartition-meta.xml new file mode 100644 index 00000000..6dd74c64 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/cachePartitions/A365Obs.cachePartition-meta.xml @@ -0,0 +1,16 @@ + + + Caches the agent-bound Observability token (~50 min) to avoid the FMI token exchange on every span. Optional; A365ObsToken degrades to no-L2-cache if capacity is unavailable. + false + A365Obs + + 1 + 0 + Organization + + + 0 + 0 + Session + + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls new file mode 100644 index 00000000..49ffffe3 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365AgentforceTool — the in-band Agentforce Apex action (@InvocableMethod). +// +// When an Agentforce agent invokes this action during a turn, it ORIGINATES an Agent 365 +// `execute_tool` span (and, via A365Telemetry, the session's `invoke_agent` root) using the +// deterministic seed scheme — making the Agentforce turn surface as a first-class agent trace +// under the configured agent identity, not just a tool span hanging off an external agent. +// +// Telemetry is fail-open + async (Queueable) + config-gated (OriginateEnabled__c): it never +// affects the turn's latency or success. +// +// The trace seed comes from the optional SessionId input when the Agent Builder maps it in, +// else a per-request fallback; the reply reports which was used. +global with sharing class A365AgentforceTool { + + global class Request { + @InvocableVariable(label='Prompt' description='Text sent from the Agentforce turn' required=true) + global String prompt; + + @InvocableVariable(label='SessionId' description='Agentforce conversation/session id, if the agent maps it into the action input. Used as the trace seed so every span of the conversation shares one trace.') + global String sessionId; + } + + global class Response { + @InvocableVariable(label='Reply' description='Text returned to the Agentforce turn') + global String reply; + } + + @InvocableMethod(label='A365 Echo' description='Echoes the prompt and originates an Agent 365 execute_tool span for this Agentforce turn (fail-open telemetry).') + global static List run(List requests) { + List responses = new List(); + if (requests == null) { + return responses; + } + for (Request r : requests) { + // Capture the Apex execution window for the span (nanoseconds, epoch-based). + Long startNs = System.now().getTime() * 1000000L; + + String prompt = (r != null && r.prompt != null) ? r.prompt : ''; + Boolean haveSession = (r != null && String.isNotBlank(r.sessionId)); + String seed = haveSession ? r.sessionId : fallbackSeed(); + + Response res = new Response(); + res.reply = 'A365 Agentforce echo: ' + + (String.isBlank(prompt) ? 'hello' : 'you said "' + prompt + '"') + + '. ' + + (haveSession + ? 'sessionId received — in-band seeding active.' + : 'no sessionId in input — fallback seed used.'); + + // Originate this turn's execute_tool span. originateToolSpan ensures the session's + // invoke_agent root exists first (deduped). Fully fail-open inside A365Telemetry. + A365Telemetry.originateToolSpan( + seed, + 'execute_tool A365AgentforceTool', + new Map{ + 'gen_ai.operation.name' => 'execute_tool', + 'gen_ai.tool.name' => 'A365AgentforceTool', + 'gen_ai.tool.type' => 'salesforce-apex', + 'sf.org.id' => UserInfo.getOrganizationId(), + 'sf.seed.source' => haveSession ? 'input.sessionId' : 'fallback' + }, + startNs, + System.now().getTime() * 1000000L, + true); + + responses.add(res); + } + return responses; + } + + // Per-request fallback seed when the builder doesn't expose a conversation id. Unique per + // invocation so a valid (if un-grouped) trace is still originated; deterministic grouping + // across a real conversation comes from the SessionId input. + private static String fallbackSeed() { + return 'af-sample-' + UserInfo.getOrganizationId() + '-' + + String.valueOf(System.now().getTime()) + '-' + + String.valueOf(Math.abs(Crypto.getRandomLong())); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceTool.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls new file mode 100644 index 00000000..f579c4ee --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for A365AgentforceTool — the in-band Agentforce action that originates +// an Agent 365 execute_tool span for a turn. Callouts (token + ingest) are mocked. +@IsTest +private class A365AgentforceToolTest { + + private static final String TENANT = '11111111-1111-1111-1111-111111111111'; + private static final String AGENT = '22222222-2222-2222-2222-222222222222'; + private static final String AF_SERVICE = 'salesforce-agentforce'; + private static final String SESSION = '44444444-4444-4444-4444-444444444444'; + + private class MultiMock implements HttpCalloutMock { + public Integer ingestCalls = 0; + public List ingestBodies = new List(); + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + if (req.getEndpoint().contains('/oauth2/')) { + res.setStatusCode(200); + res.setBody('{"access_token":"FAKE","expires_in":3599}'); + } else { + ingestCalls++; + ingestBodies.add(req.getBody()); + res.setStatusCode(200); + res.setHeader('x-ms-correlation-id', 'test-corr'); + res.setBody('{"partialSuccess":{"rejectedSpans":0}}'); + } + return res; + } + } + + private static A365_Observability_Config__mdt cfg(Boolean originateEnabled) { + return new A365_Observability_Config__mdt( + Enabled__c = true, + OriginateEnabled__c = originateEnabled, + AgentforceServiceName__c = AF_SERVICE, + TenantId__c = TENANT, + AgentId__c = AGENT, + IngestBase__c = 'https://agent365.svc.cloud.microsoft', + ObsScope__c = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + FmiScope__c = 'api://AzureADTokenExchange/.default', + UseS2SEndpoint__c = true, + ServiceName__c = 'salesforce-apex'); + } + + private static A365AgentforceTool.Request req(String prompt, String sessionId) { + A365AgentforceTool.Request r = new A365AgentforceTool.Request(); + r.prompt = prompt; + r.sessionId = sessionId; + return r; + } + + private static Map firstSpan(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map ss = (Map) ((List) rs.get('scopeSpans'))[0]; + return (Map) ((List) ss.get('spans'))[0]; + } + + @IsTest + static void run_withSessionId_echoesAndOriginatesSeededTrace() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + List out = + A365AgentforceTool.run(new List{ + req('hi there', SESSION) }); + Test.stopTest(); + + System.assertEquals(1, out.size(), 'one response per request'); + System.assert(out[0].reply.contains('you said "hi there"'), 'echoes the prompt: ' + out[0].reply); + System.assert(out[0].reply.contains('sessionId received'), 'reports in-band seeding'); + + // Origination used the SessionId as the trace seed (invoke_agent root + execute_tool child). + String expectedTrace = A365Trace.traceIdFromSeed(SESSION); + System.assert(mock.ingestCalls >= 1, 'at least one span POSTed'); + Boolean sawExecuteTool = false; + for (String body : mock.ingestBodies) { + Map sp = firstSpan(body); + System.assertEquals(expectedTrace, (String) sp.get('traceId'), + 'span uses the SessionId-seeded traceId'); + Map attrs = (Map) sp.get('attributes'); + if (attrs.get('gen_ai.operation.name') == 'execute_tool') { + sawExecuteTool = true; + System.assertEquals('A365AgentforceTool', attrs.get('gen_ai.tool.name'), 'tool name'); + System.assertEquals('input.sessionId', attrs.get('sf.seed.source'), 'seed source attr'); + System.assertEquals(A365Trace.spanIdFromSeed(SESSION), (String) sp.get('parentSpanId'), + 'child nests under the seed-derived root'); + } + } + System.assert(sawExecuteTool, 'an execute_tool span was originated'); + } + + @IsTest + static void run_withoutSessionId_usesFallbackButStillOriginates() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + List out = + A365AgentforceTool.run(new List{ + req('no session', null) }); + Test.stopTest(); + + System.assert(out[0].reply.contains('no sessionId in input'), 'reports fallback: ' + out[0].reply); + System.assert(mock.ingestCalls >= 1, 'fallback seed still originates a trace'); + Boolean sawFallbackSource = false; + for (String body : mock.ingestBodies) { + Map attrs = (Map) firstSpan(body).get('attributes'); + if (attrs.get('gen_ai.operation.name') == 'execute_tool') { + System.assertEquals('fallback', attrs.get('sf.seed.source'), 'seed source = fallback'); + sawFallbackSource = true; + } + } + System.assert(sawFallbackSource, 'execute_tool span emitted with fallback seed'); + } + + @IsTest + static void run_originationDisabled_stillRepliesNoCallout() { + A365ObsConfig.overrideRecord = cfg(false); // OriginateEnabled__c = false + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + List out = + A365AgentforceTool.run(new List{ + req('hello', SESSION) }); + Test.stopTest(); + + System.assertEquals(1, out.size(), 'still returns a reply when telemetry is off'); + System.assert(out[0].reply.contains('you said "hello"'), 'echo unaffected by telemetry gate'); + System.assertEquals(0, mock.ingestCalls, 'disabled origination => no callout'); + } + + @IsTest + static void run_nullAndEmpty_handledGracefully() { + A365ObsConfig.overrideRecord = cfg(true); + Test.setMock(HttpCalloutMock.class, new MultiMock()); + + Test.startTest(); + System.assertEquals(0, A365AgentforceTool.run(null).size(), 'null input => empty'); + List out = + A365AgentforceTool.run(new List{ + req(null, null) }); + Test.stopTest(); + + System.assertEquals(1, out.size(), 'null prompt still yields a reply'); + System.assert(out[0].reply.contains('hello'), 'blank prompt => greeting'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365AgentforceToolTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls b/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls new file mode 100644 index 00000000..b99b4dba --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365Callout — Salesforce -> A365 outbound HTTP callout. +// +// Performs an outbound POST from Apex into the A365 /callback endpoint. +// The endpoint is referenced via a Named Credential so the URL lives in one +// place (Setup -> Named Credentials -> "A365_Callback") and no Remote Site +// Setting is required. See README for the one-time Named Credential setup. +// +// Trigger it any of these ways: +// - Execute Anonymous: System.debug(A365Callout.callA365('hello from apex')); +// - From Agentforce/Flow via the @InvocableMethod below +// - From another Apex class / REST resource + +public with sharing class A365Callout { + + public class CalloutInput { + @InvocableVariable(label='Message' description='Text to send to A365' required=true) + public String message; + @InvocableVariable(label='Traceparent' description='Optional W3C traceparent to correlate this callback into an existing A365 trace') + public String traceparent; + } + + public class CalloutResult { + @InvocableVariable(label='Status Code') + public Integer statusCode; + @InvocableVariable(label='Response Body') + public String responseBody; + } + + @InvocableMethod(label='Call A365 Callback' description='POSTs a message to the A365 /callback endpoint and returns the response.') + public static List callFromFlow(List inputs) { + List results = new List(); + for (CalloutInput input : inputs) { + results.add(callA365( + input != null ? input.message : '', + input != null ? input.traceparent : null + )); + } + return results; + } + + // Core HTTP callout into A365. Uses Named Credential 'A365_Callback'. + public static CalloutResult callA365(String message) { + return callA365(message, null); + } + + // Overload that forwards a W3C trace context so the Salesforce -> A365 + // callback leg continues an existing trace instead of starting an unrelated one. + public static CalloutResult callA365(String message, String traceparent) { + Long startNs = System.now().getTime() * 1000000L; + HttpRequest req = new HttpRequest(); + req.setEndpoint('callout:A365_Callback/callback'); + req.setMethod('POST'); + req.setHeader('Content-Type', 'application/json'); + if (String.isNotBlank(traceparent)) { + req.setHeader('traceparent', traceparent); + } + req.setTimeout(20000); + + Map payload = new Map{ + 'source' => 'salesforce-apex', + 'orgId' => UserInfo.getOrganizationId(), + 'user' => UserInfo.getUserName(), + 'message' => message + }; + req.setBody(JSON.serialize(payload)); + + CalloutResult result = new CalloutResult(); + try { + HttpResponse res = new Http().send(req); + result.statusCode = res.getStatusCode(); + result.responseBody = res.getBody(); + } catch (Exception e) { + result.statusCode = -1; + result.responseBody = 'Callout failed: ' + e.getMessage(); + } + + // Native Apex observability: emit a CLIENT span for this outbound A365 callback leg, + // reusing the forwarded traceparent so it nests in the same agent trace. Fail-open + + // async; no-op when traceparent is blank (never fabricate a trace). + Boolean ok = result.statusCode != null && result.statusCode >= 200 && result.statusCode < 300; + A365Telemetry.emitToolSpan( + traceparent, 'execute_tool A365Callout', 'CLIENT', + new Map{ + 'gen_ai.operation.name' => 'execute_tool', + 'gen_ai.tool.name' => 'A365Callout', + 'gen_ai.tool.type' => 'salesforce-apex', + 'sf.org.id' => UserInfo.getOrganizationId(), + 'http.response.status_code' => result.statusCode + }, + startNs, System.now().getTime() * 1000000L, ok); + + return result; + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Callout.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls new file mode 100644 index 00000000..da4a1976 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit test for A365Callout. Apex requires an HttpCalloutMock to test callouts +// and to provide code coverage for deployment. + +@IsTest +private class A365CalloutTest { + + private class A365Mock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + // Assert the request looks right. + System.assertEquals('POST', req.getMethod(), 'Should POST to A365'); + System.assert(req.getEndpoint().endsWith('/callback'), 'Should hit /callback'); + System.assert(req.getBody().contains('salesforce-apex'), 'Body should identify the source'); + + HttpResponse res = new HttpResponse(); + res.setStatusCode(200); + res.setHeader('Content-Type', 'application/json'); + res.setBody('{"ok":true,"reply":"ack from A365"}'); + return res; + } + } + + @IsTest + static void callA365_returnsResponse() { + Test.setMock(HttpCalloutMock.class, new A365Mock()); + + Test.startTest(); + A365Callout.CalloutResult result = A365Callout.callA365('hello from apex'); + Test.stopTest(); + + System.assertEquals(200, result.statusCode, 'Expected HTTP 200'); + System.assert(result.responseBody.contains('ack from A365'), 'Expected A365 ack in body'); + } + + @IsTest + static void callFromFlow_handlesList() { + Test.setMock(HttpCalloutMock.class, new A365Mock()); + + A365Callout.CalloutInput input = new A365Callout.CalloutInput(); + input.message = 'from flow'; + + Test.startTest(); + List results = + A365Callout.callFromFlow(new List{ input }); + Test.stopTest(); + + System.assertEquals(1, results.size(), 'One result expected'); + System.assertEquals(200, results[0].statusCode, 'Expected HTTP 200'); + } + + // ---- Observability emission -------------------------------------- + + private static final String TRACE = '4bf92f3577b34da6a3ce929d0e0e4736'; + private static final String TRACEPARENT = '00-' + TRACE + '-00f067aa0ba902b7-01'; + + // Routes /callback (the real leg) vs token (/oauth2/) vs ingest (/otlp/), so the async + // CLIENT-span emit can flush alongside the primary callout. + private class RouterMock implements HttpCalloutMock { + public Integer ingestCalls = 0; + public String lastIngestBody; + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + String ep = req.getEndpoint(); + if (ep.contains('/oauth2/')) { + res.setStatusCode(200); + res.setBody('{"access_token":"FAKE","expires_in":3599}'); + } else if (ep.contains('/otlp/')) { + ingestCalls++; + lastIngestBody = req.getBody(); + res.setStatusCode(200); + res.setBody('{"partialSuccess":{"rejectedSpans":0}}'); + } else { + res.setStatusCode(200); + res.setBody('{"ok":true,"reply":"ack from A365"}'); + } + return res; + } + } + + private static A365_Observability_Config__mdt cfg(Boolean enabled) { + return new A365_Observability_Config__mdt( + Enabled__c = enabled, + TenantId__c = '11111111-1111-1111-1111-111111111111', + AgentId__c = '22222222-2222-2222-2222-222222222222', + IngestBase__c = 'https://agent365.svc.cloud.microsoft', + ObsScope__c = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + FmiScope__c = 'api://AzureADTokenExchange/.default', + UseS2SEndpoint__c = true, + ServiceName__c = 'salesforce-apex'); + } + + @IsTest + static void callA365_emissionEnabled_resultUnchanged_andEmitsClientSpan() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + RouterMock mock = new RouterMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Callout.CalloutResult result = A365Callout.callA365('hello', TRACEPARENT); + Test.stopTest(); + + // Primary callout contract unchanged. + System.assertEquals(200, result.statusCode, 'callback still 200'); + System.assert(result.responseBody.contains('ack from A365'), 'callback body unchanged'); + + // A CLIENT span was emitted on the inbound trace. + System.assertEquals(1, mock.ingestCalls, 'one client span emitted'); + System.assert(mock.lastIngestBody.contains(TRACE), 'span reuses inbound traceId'); + System.assert(mock.lastIngestBody.contains('CLIENT'), 'outbound leg is a CLIENT span'); + } + + @IsTest + static void callA365_emissionDisabled_isNoOp() { + A365ObsConfig.overrideRecord = cfg(false); + RouterMock mock = new RouterMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Callout.CalloutResult result = A365Callout.callA365('hello', TRACEPARENT); + Test.stopTest(); + + System.assertEquals(200, result.statusCode, 'callback still 200 when disabled'); + System.assertEquals(0, mock.ingestCalls, 'disabled => no emission (regression guard)'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365CalloutTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls new file mode 100644 index 00000000..e2b20680 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365ObsConfig — thin reader over the A365_Observability_Config__mdt 'Default' record. +// +// Centralizes the non-secret runtime config (enable flag, ids, endpoints, scopes) so the +// emitter, token service and queueable read one source of truth. NEVER holds secrets — +// the blueprint secret lives only in the External Credential (Setup-entered). +// +// Resilient by design: if the Default record is missing (e.g. a fresh scratch org), the +// reader falls back to built-in default constants so the pipeline degrades gracefully rather +// than throwing. Tests can force a config via the @TestVisible override. +public with sharing class A365ObsConfig { + + // Default fallbacks — used only when the Default CMDT record is absent. + @TestVisible private static final String DEF_TENANT = '11111111-1111-1111-1111-111111111111'; + @TestVisible private static final String DEF_AGENT = '22222222-2222-2222-2222-222222222222'; + @TestVisible private static final String DEF_INGEST_BASE = 'https://agent365.svc.cloud.microsoft'; + @TestVisible private static final String DEF_OBS_SCOPE = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default'; + @TestVisible private static final String DEF_FMI_SCOPE = 'api://AzureADTokenExchange/.default'; + @TestVisible private static final String DEF_SERVICE_NAME = 'salesforce-apex'; + @TestVisible private static final String DEF_AF_SERVICE_NAME = 'salesforce-agentforce'; + + // Test override — when set, getInstance() returns this instead of querying CMDT + // (CMDT rows cannot be inserted in tests, so this is how disabled/enabled is exercised). + @TestVisible private static A365_Observability_Config__mdt overrideRecord; + + // Cached per-transaction so repeated reads don't re-query. + private static A365_Observability_Config__mdt cached; + + @TestVisible + private static A365_Observability_Config__mdt getInstance() { + if (overrideRecord != null) { + return overrideRecord; + } + if (cached == null) { + cached = A365_Observability_Config__mdt.getInstance('Default'); + } + return cached; + } + + public static Boolean isEnabled() { + A365_Observability_Config__mdt c = getInstance(); + // Absent record => treat as DISABLED (fail-safe: never emit without explicit config). + return c != null && c.Enabled__c == true; + } + + public static String tenantId() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.TenantId__c)) ? c.TenantId__c : DEF_TENANT; + } + + public static String agentId() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.AgentId__c)) ? c.AgentId__c : DEF_AGENT; + } + + public static String ingestBase() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.IngestBase__c)) ? c.IngestBase__c : DEF_INGEST_BASE; + } + + public static String obsScope() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.ObsScope__c)) ? c.ObsScope__c : DEF_OBS_SCOPE; + } + + public static String fmiScope() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.FmiScope__c)) ? c.FmiScope__c : DEF_FMI_SCOPE; + } + + public static String serviceName() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.ServiceName__c)) ? c.ServiceName__c : DEF_SERVICE_NAME; + } + + // --- Origination (Agentforce-native) config ---------------------------------- + + // service.name for Salesforce-ORIGINATED (Agentforce) traces — disambiguates this path + // from the boundary emitter when both share the mapped agentId. + public static String agentforceServiceName() { + A365_Observability_Config__mdt c = getInstance(); + return (c != null && String.isNotBlank(c.AgentforceServiceName__c)) + ? c.AgentforceServiceName__c : DEF_AF_SERVICE_NAME; + } + + // Master switch for the origination path, independent of the boundary Enabled__c. + // Absent record/field => DISABLED (fail-safe: never originate without explicit opt-in). + public static Boolean originateEnabled() { + A365_Observability_Config__mdt c = getInstance(); + return c != null && c.OriginateEnabled__c == true; + } + + 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; + } + + // The ingest path for the OTLP traces POST, honoring UseS2SEndpoint__c. + public static String tracesPath() { + String svc = useS2SEndpoint() ? 'observabilityService' : 'observability'; + return '/' + svc + '/tenants/' + tenantId() + + '/otlp/agents/' + agentId() + '/traces?api-version=1'; + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsConfig.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls new file mode 100644 index 00000000..d471611e --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365ObsSpan — builds the Agent 365 OTLP span JSON body. +// +// IMPORTANT: this mirrors the Agent 365 OTLP wire shape the A365 ingest accepts today +// (the ground truth), NOT hand-rolled "standard OTLP": +// - `attributes` is a flat object MAP (not an array of {key,value}). +// - `kind` and `status.code` are enum STRINGS (not integers). +// - `*UnixNano` are JSON NUMBERS. +// - resource key is `microsoft.tenant.id`; the request header is `x-ms-tenant-id`. +// If the ingest ever returns 400 on this shape, the documented fallback is standard +// OTLP (array-of-keyvalue, integer enums) — see docs/design.md -> "OTLP wire shape". +// +// Pure (no callouts) so it is trivially unit-testable. +public with sharing class A365ObsSpan { + + // Default constants — overridden by the A365_Observability_Config__mdt 'Default' record. + @TestVisible private static final String SERVICE_NAME = 'salesforce-apex'; + @TestVisible private static final String SCOPE_NAME = 'salesforce-apex'; + @TestVisible private static final String SCOPE_VERSION = '0.1.0'; + @TestVisible private static final String SDK_NAME = 'A365ObservabilitySDK-Apex'; + @TestVisible private static final String SDK_LANGUAGE = 'apex'; + + // Build the OTLP body for a single span. + // tenantId/agentId : routing + auth keys (also surfaced as attributes). + // traceId : 32-hex from the inbound traceparent — NEVER regenerated. + // parentSpanId : 16-hex from the inbound traceparent (null => omitted, root span). + // spanId : 16-hex freshly minted for this span. + // name : span name, e.g. 'execute_tool A365ToolRest'. + // kind : INTERNAL|SERVER|CLIENT|PRODUCER|CONSUMER|UNSPECIFIED. + // statusCode : UNSET|OK|ERROR. + // attrs : extra span attributes (flat map). gen_ai.agent.id is added if absent. + public static String buildBody( + String tenantId, String agentId, String traceId, String parentSpanId, String spanId, + String name, String kind, String statusCode, Map attrs + ) { + // Convenience overload: distinct start/end timestamps (now .. now+1ms) in nanoseconds. + Long startNs = System.now().getTime() * 1000000L; + Long endNs = startNs + 1000000L; + return buildBody(tenantId, agentId, traceId, parentSpanId, spanId, name, kind, + statusCode, attrs, startNs, endNs); + } + + // Full overload with caller-supplied nanosecond timestamps (used by the productionized + // emitter so the span reflects real Apex execution timing). + public static String buildBody( + String tenantId, String agentId, String traceId, String parentSpanId, String spanId, + String name, String kind, String statusCode, Map attrs, + Long startNs, Long endNs + ) { + return buildBody(tenantId, agentId, traceId, parentSpanId, spanId, name, kind, + statusCode, attrs, startNs, endNs, null); + } + + // Overload allowing the resource `service.name` to be overridden per path. The + // origination (Agentforce) path passes A365ObsConfig.agentforceServiceName() so its + // traces are distinguishable from the boundary emitter under a shared agentId. + // serviceName blank/null => the default boundary SERVICE_NAME (unchanged behavior). + public static String buildBody( + String tenantId, String agentId, String traceId, String parentSpanId, String spanId, + String name, String kind, String statusCode, Map attrs, + Long startNs, Long endNs, String serviceName + ) { + String svcName = String.isNotBlank(serviceName) ? serviceName : SERVICE_NAME; + Map resourceAttrs = new Map{ + 'service.name' => svcName, + 'microsoft.tenant.id' => tenantId, + 'gen_ai.agent.id' => agentId, + 'telemetry.sdk.name' => SDK_NAME, + 'telemetry.sdk.language' => SDK_LANGUAGE + }; + + Map spanAttrs = new Map(); + if (attrs != null) { + spanAttrs.putAll(attrs); + } + // gen_ai.agent.id must appear on the span too (routing/identity, not just a label). + if (!spanAttrs.containsKey('gen_ai.agent.id')) { + spanAttrs.put('gen_ai.agent.id', agentId); + } + + Map span = new Map{ + 'traceId' => traceId, + 'spanId' => spanId, + 'name' => name, + 'kind' => kind, + 'startTimeUnixNano' => startNs, + 'endTimeUnixNano' => endNs, + 'attributes' => spanAttrs, + 'status' => new Map{ + 'code' => statusCode, + 'message' => '' + } + }; + // Omit parentSpanId entirely when absent (root span) rather than send null/empty. + if (String.isNotBlank(parentSpanId)) { + span.put('parentSpanId', parentSpanId); + } + + Map resourceSpan = new Map{ + 'resource' => new Map{ 'attributes' => resourceAttrs }, + 'scopeSpans' => new List{ + new Map{ + 'scope' => new Map{ + 'name' => SCOPE_NAME, + 'version' => SCOPE_VERSION + }, + 'spans' => new List{ span } + } + } + }; + + Map root = new Map{ + 'resourceSpans' => new List{ resourceSpan } + }; + + // suppressApexObjectNulls = true: keeps the body clean if any value is null. + return JSON.serialize(root, true); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpan.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls new file mode 100644 index 00000000..6995972b --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for A365ObsSpan (pure — no callouts). Asserts the live exporter wire shape: +// flat-map attributes, string kind/status, numeric *UnixNano, trace/parent reuse. +@IsTest +private class A365ObsSpanTest { + + private static final String TENANT = '11111111-1111-1111-1111-111111111111'; + private static final String AGENT = '22222222-2222-2222-2222-222222222222'; + private static final String TRACE = '4bf92f3577b34da6a3ce929d0e0e4736'; + private static final String PARENT = '00f067aa0ba902b7'; + private static final String SPAN_ID = '00f067aa0ba90211'; + + private static Map firstSpan(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + List resourceSpans = (List) root.get('resourceSpans'); + Map rs = (Map) resourceSpans[0]; + List scopeSpans = (List) rs.get('scopeSpans'); + Map ss = (Map) scopeSpans[0]; + List spans = (List) ss.get('spans'); + return (Map) spans[0]; + } + + private static Map resourceAttrs(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map resource = (Map) rs.get('resource'); + return (Map) resource.get('attributes'); + } + + @IsTest + static void buildBody_reusesTraceAndParent() { + String body = A365ObsSpan.buildBody(TENANT, AGENT, TRACE, PARENT, SPAN_ID, + 'execute_tool A365ToolRest', 'SERVER', 'OK', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }); + + Map span = firstSpan(body); + System.assertEquals(TRACE, (String) span.get('traceId'), 'traceId reused, not regenerated'); + System.assertEquals(PARENT, (String) span.get('parentSpanId'), 'parentSpanId reused'); + System.assertEquals(SPAN_ID, (String) span.get('spanId'), 'spanId as provided'); + System.assertEquals('execute_tool A365ToolRest', (String) span.get('name'), 'name'); + System.assertEquals('SERVER', (String) span.get('kind'), 'kind is a string enum'); + } + + @IsTest + static void buildBody_stringStatusAndFlatAttributes() { + String body = A365ObsSpan.buildBody(TENANT, AGENT, TRACE, PARENT, SPAN_ID, + 'execute_tool A365ToolRest', 'SERVER', 'OK', + new Map{ + 'gen_ai.operation.name' => 'execute_tool', + 'sf.prompt.len' => 19 + }); + + Map span = firstSpan(body); + // status.code is a STRING enum, not an integer. + Map status = (Map) span.get('status'); + System.assertEquals('OK', status.get('code'), 'status.code string enum'); + + // attributes is a flat MAP, not an array of {key,value}. + Object attrsObj = span.get('attributes'); + System.assert(attrsObj instanceof Map, 'attributes is a flat map'); + Map attrs = (Map) attrsObj; + System.assertEquals('execute_tool', attrs.get('gen_ai.operation.name'), 'op name'); + System.assertEquals(19, attrs.get('sf.prompt.len'), 'numeric attr preserved'); + // gen_ai.agent.id auto-added to the span attributes. + System.assertEquals(AGENT, attrs.get('gen_ai.agent.id'), 'agent id on span'); + } + + @IsTest + static void buildBody_resourceAttributesPresent() { + String body = A365ObsSpan.buildBody(TENANT, AGENT, TRACE, PARENT, SPAN_ID, + 'execute_tool A365ToolRest', 'SERVER', 'OK', null); + Map ra = resourceAttrs(body); + System.assertEquals(TENANT, ra.get('microsoft.tenant.id'), 'resource tenant id'); + System.assertEquals(AGENT, ra.get('gen_ai.agent.id'), 'resource agent id'); + System.assertEquals('salesforce-apex', ra.get('service.name'), 'service name'); + System.assertEquals('apex', ra.get('telemetry.sdk.language'), 'sdk language'); + } + + @IsTest + static void buildBody_timestampsAreNumbers() { + Long startNs = 1750000000000000000L; + Long endNs = 1750000000123000000L; + String body = A365ObsSpan.buildBody(TENANT, AGENT, TRACE, PARENT, SPAN_ID, + 'execute_tool A365ToolRest', 'SERVER', 'OK', null, startNs, endNs); + + // Serialized as bare JSON numbers (no quotes). + System.assert(body.contains('"startTimeUnixNano":1750000000000000000'), + 'start ns serialized as number: ' + body); + System.assert(body.contains('"endTimeUnixNano":1750000000123000000'), + 'end ns serialized as number'); + + Map span = firstSpan(body); + System.assertEquals(startNs, (Long) span.get('startTimeUnixNano'), 'start ns value'); + System.assertEquals(endNs, (Long) span.get('endTimeUnixNano'), 'end ns value'); + } + + @IsTest + static void buildBody_omitsParentWhenBlank() { + String body = A365ObsSpan.buildBody(TENANT, AGENT, TRACE, null, SPAN_ID, + 'execute_tool A365ToolRest', 'SERVER', 'OK', null); + Map span = firstSpan(body); + System.assert(!span.containsKey('parentSpanId'), 'root span omits parentSpanId'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsSpanTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls new file mode 100644 index 00000000..653c4776 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// 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 +// agent-bound token via the FMI 3-hop (JWT-bearer client-credentials), sponsored by the +// agent BLUEPRINT app (which the agent identity already federates): +// +// Hop 1/2 (as blueprint): client_credentials + scope=api://AzureADTokenExchange/.default +// + fmi_path= => T1 (FMI assertion). +// Client auth rides as `Authorization: Basic` injected by the +// `A365_Obs_Token` Named Credential custom header (secret never in Apex/git). +// Hop 3 (as agent id): client_credentials + client_id= +// + client_assertion_type=jwt-bearer + client_assertion=T1 +// + scope=/.default => agent-bound Observability token. +// Sent via the `A365_Obs_TokenJwt` Named Credential, which injects NO +// Authorization header (Hop 3 authenticates with the assertion, not Basic). +// +// MSAL is unavailable in Apex; each hop is a raw application/x-www-form-urlencoded POST. +// `getToken()` caches the agent-bound token (L1 per-transaction static + L2 Platform Cache +// when provisioned; TTL from the JWT `exp`), so a high-frequency emit path does not repeat +// the token exchange on every span. See docs/design.md -> "Token model (FMI 3-hop, agent-bound)". +// Config (tenant/agent/scopes) is sourced from A365ObsConfig (CMDT) with default fallbacks. +public with sharing class A365ObsToken { + + // Named Credentials (defined under namedCredentials/; one-time setup in the README). + private static final String NC_TOKEN_BASIC = 'A365_Obs_Token'; // Hop 1/2: Basic header injected. + private static final String NC_TOKEN_JWT = 'A365_Obs_TokenJwt'; // Hop 3: no Authorization header. + + // Platform Cache (optional). Used opportunistically; absence is non-fatal. + private static final String CACHE_PARTITION = 'local.A365Obs'; + private static final String CACHE_KEY = 'obsToken'; + // Refresh a little before the real expiry to avoid using a just-expired token. + private static final Integer EXPIRY_SKEW_SECONDS = 300; // 5 min safety margin. + private static final Integer DEFAULT_TTL_SECONDS = 3000; // ~50 min when exp is unreadable. + + // L1 (per-transaction) cache so repeated emits in one transaction never re-POST, + // independent of whether Platform Cache capacity is provisioned. + @TestVisible private static String l1Token; + @TestVisible private static Long l1ExpiryEpochSeconds; + + public class A365ObsTokenException extends Exception {} + + // Acquire an agent-bound Observability bearer token, cached. Falls back to a fresh + // token acquisition on a cache miss/expiry. Use this in production paths. + public static String getToken() { + Long nowSec = System.now().getTime() / 1000; + + // L1: same-transaction. + if (String.isNotBlank(l1Token) && l1ExpiryEpochSeconds != null + && nowSec < l1ExpiryEpochSeconds) { + return l1Token; + } + + // L2: Platform Cache (best-effort). + CachedToken fromCache = readCache(); + if (fromCache != null && nowSec < fromCache.expiryEpochSeconds) { + l1Token = fromCache.token; + l1ExpiryEpochSeconds = fromCache.expiryEpochSeconds; + return l1Token; + } + + // Miss/expired: acquire fresh and populate both cache levels. + String token = getTokenNoCache(); + Long expiry = expiryEpochFromJwt(token); + l1Token = token; + l1ExpiryEpochSeconds = expiry; + writeCache(token, expiry, nowSec); + return token; + } + + // Acquire an agent-bound Observability bearer token (no caching). Does Hop 1/2 then Hop 3. + public static String getTokenNoCache() { + String t1 = acquireT1(); + return acquireObsToken(t1); + } + + // Clears the per-transaction cache (used by tests). + @TestVisible + private static void clearCache() { + l1Token = null; + l1ExpiryEpochSeconds = null; + } + + // Hop 1/2: blueprint client-credentials with fmi_path => FMI assertion (T1). + @TestVisible + private static String acquireT1() { + String body = 'grant_type=client_credentials' + + '&scope=' + EncodingUtil.urlEncode(A365ObsConfig.fmiScope(), 'UTF-8') + + '&fmi_path=' + EncodingUtil.urlEncode(A365ObsConfig.agentId(), 'UTF-8'); + return postForToken(NC_TOKEN_BASIC, body, 'Hop1/2'); + } + + // Hop 3: JWT-bearer client-assertion => agent-bound Observability token. + @TestVisible + private static String acquireObsToken(String t1) { + String body = 'grant_type=client_credentials' + + '&client_id=' + EncodingUtil.urlEncode(A365ObsConfig.agentId(), 'UTF-8') + + '&client_assertion_type=' + EncodingUtil.urlEncode('urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 'UTF-8') + + '&client_assertion=' + EncodingUtil.urlEncode(t1, 'UTF-8') + + '&scope=' + EncodingUtil.urlEncode(A365ObsConfig.obsScope(), 'UTF-8'); + return postForToken(NC_TOKEN_JWT, body, 'Hop3'); + } + + // ---- Token caching helpers ------------------------------------------------- + + private class CachedToken { + String token; + Long expiryEpochSeconds; + } + + // Reads the L2 (Platform Cache) token. Any error (no partition/capacity) => null. + private static CachedToken readCache() { + try { + Cache.OrgPartition partition = Cache.Org.getPartition(CACHE_PARTITION); + if (partition == null || !partition.contains(CACHE_KEY)) { + return null; + } + Map stored = (Map) partition.get(CACHE_KEY); + if (stored == null) { + return null; + } + CachedToken ct = new CachedToken(); + ct.token = (String) stored.get('token'); + ct.expiryEpochSeconds = (Long) stored.get('exp'); + return String.isBlank(ct.token) || ct.expiryEpochSeconds == null ? null : ct; + } catch (Exception e) { + // Platform Cache unavailable in this org — degrade to no L2 cache. + return null; + } + } + + // Writes the L2 (Platform Cache) token. Best-effort; never throws. + private static void writeCache(String token, Long expiryEpochSeconds, Long nowSec) { + try { + Cache.OrgPartition partition = Cache.Org.getPartition(CACHE_PARTITION); + if (partition == null) { + return; + } + Integer ttl = (Integer) (expiryEpochSeconds - nowSec); + if (ttl == null || ttl <= 0) { + ttl = DEFAULT_TTL_SECONDS; + } + // Platform Cache org TTL ceiling is 48h; our tokens are ~1h so no clamp needed. + partition.put(CACHE_KEY, + new Map{ 'token' => token, 'exp' => expiryEpochSeconds }, + ttl); + } catch (Exception e) { + // No cache capacity — fine, we simply re-acquire next transaction. + } + } + + // Derives an absolute expiry (epoch seconds, minus a safety skew) from the JWT `exp` + // claim. Falls back to now + DEFAULT_TTL when the token can't be parsed. + @TestVisible + private static Long expiryEpochFromJwt(String jwt) { + Long nowSec = System.now().getTime() / 1000; + try { + List parts = jwt.split('\\.'); + if (parts.size() >= 2) { + String payloadJson = base64UrlDecode(parts[1]); + Map claims = (Map) JSON.deserializeUntyped(payloadJson); + Object expObj = claims.get('exp'); + if (expObj != null) { + Long exp = (expObj instanceof Long) ? (Long) expObj : Long.valueOf(String.valueOf(expObj)); + return exp - EXPIRY_SKEW_SECONDS; + } + } + } catch (Exception e) { + // Unparseable token — use the conservative default below. + } + return nowSec + DEFAULT_TTL_SECONDS; + } + + private static String base64UrlDecode(String input) { + String b64 = input.replace('-', '+').replace('_', '/'); + Integer pad = Math.mod(b64.length(), 4); + if (pad > 0) { + b64 += '===='.substring(pad); + } + return EncodingUtil.base64Decode(b64).toString(); + } + + // Shared token-endpoint POST + access_token extraction. + private static String postForToken(String namedCredential, String body, String hopLabel) { + HttpRequest req = new HttpRequest(); + req.setEndpoint('callout:' + namedCredential + '/' + A365ObsConfig.tenantId() + '/oauth2/v2.0/token'); + req.setMethod('POST'); + req.setHeader('Content-Type', 'application/x-www-form-urlencoded'); + req.setHeader('Accept', 'application/json'); + req.setTimeout(20000); + req.setBody(body); + + HttpResponse res = new Http().send(req); + Integer status = res.getStatusCode(); + if (status < 200 || status >= 300) { + throw new A365ObsTokenException(hopLabel + ' token request failed: HTTP ' + + status + ' ' + res.getBody()); + } + Map parsed = (Map) JSON.deserializeUntyped(res.getBody()); + String accessToken = (String) parsed.get('access_token'); + if (String.isBlank(accessToken)) { + throw new A365ObsTokenException(hopLabel + ' response had no access_token: ' + res.getBody()); + } + return accessToken; + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsToken.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls new file mode 100644 index 00000000..40abc208 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for A365ObsToken. Mocks the two token-endpoint POSTs (Hop 1/2, Hop 3) and +// asserts each hop's form body, plus that getTokenNoCache() returns the Hop-3 token. +@IsTest +private class A365ObsTokenTest { + + // Records each request so the test can assert hop ordering + body contents, and + // returns a fake T1 first then a fake observability token. + private class TokenMock implements HttpCalloutMock { + public Integer calls = 0; + public List bodies = new List(); + public List endpoints = new List(); + + public HttpResponse respond(HttpRequest req) { + calls++; + bodies.add(req.getBody()); + endpoints.add(req.getEndpoint()); + System.assertEquals('POST', req.getMethod(), 'token POST'); + System.assertEquals('application/x-www-form-urlencoded', + req.getHeader('Content-Type'), 'form-encoded body'); + + HttpResponse res = new HttpResponse(); + res.setStatusCode(200); + res.setHeader('Content-Type', 'application/json'); + String accessToken = (calls == 1) ? 'FAKE_T1_ASSERTION' : 'FAKE_OBS_TOKEN'; + res.setBody('{"token_type":"Bearer","expires_in":3599,"access_token":"' + accessToken + '"}'); + return res; + } + } + + @IsTest + static void getTokenNoCache_doesBothHops_returnsObsToken() { + TokenMock mock = new TokenMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + String token = A365ObsToken.getTokenNoCache(); + Test.stopTest(); + + System.assertEquals('FAKE_OBS_TOKEN', token, 'returns the Hop-3 obs token'); + System.assertEquals(2, mock.calls, 'exactly two token POSTs (Hop1/2 + Hop3)'); + + // Hop 1/2 body: fmi_path + AzureADTokenExchange scope, via the Basic-header NC. + String hop12 = mock.bodies[0]; + System.assert(hop12.contains('grant_type=client_credentials'), 'hop1/2 grant'); + System.assert(hop12.contains('fmi_path='), 'hop1/2 has fmi_path'); + System.assert(hop12.contains('AzureADTokenExchange'), 'hop1/2 FMI scope'); + System.assert(mock.endpoints[0].contains('A365_Obs_Token/'), 'hop1/2 uses Basic NC'); + + // Hop 3 body: jwt-bearer client-assertion = T1, client_id = agentId, obs scope. + String hop3 = mock.bodies[1]; + System.assert(hop3.contains('client_assertion_type='), 'hop3 assertion type'); + System.assert(hop3.contains('jwt-bearer'), 'hop3 jwt-bearer grant'); + System.assert(hop3.contains('client_assertion=FAKE_T1_ASSERTION'), 'hop3 carries T1'); + System.assert(hop3.contains('client_id='), 'hop3 client_id'); + System.assert(hop3.contains('9b975845'), 'hop3 obs scope'); + System.assert(mock.endpoints[1].contains('A365_Obs_TokenJwt/'), 'hop3 uses no-auth NC'); + } + + @IsTest + static void tokenRequest_nonSuccess_throws() { + Test.setMock(HttpCalloutMock.class, new FailMock()); + Test.startTest(); + try { + A365ObsToken.getTokenNoCache(); + System.assert(false, 'expected an exception on HTTP 401'); + } catch (A365ObsToken.A365ObsTokenException e) { + System.assert(e.getMessage().contains('401'), 'surfaces the HTTP status'); + } + Test.stopTest(); + } + + private class FailMock implements HttpCalloutMock { + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setStatusCode(401); + res.setBody('{"error":"invalid_client"}'); + return res; + } + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ObsTokenTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls b/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls new file mode 100644 index 00000000..e72b8952 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365Telemetry — public façade for emitting a Salesforce-originated Agent 365 OTLP span. +// +// This is the ONLY entry point the business code (A365ToolRest, A365Callout) calls. It is +// deliberately tiny and **fail-open**: it must never slow or break Flow 1/Flow 2. The actual +// token dance + HTTP POST happen asynchronously in A365TelemetryQueueable, after the caller's +// synchronous response has already been returned. +// +// Behavior: +// - gated on A365_Observability_Config__mdt.Enabled__c (master kill-switch) -> no-op when off; +// - requires a valid inbound `traceparent` -> NEVER fabricates a trace (no traceparent = no-op), +// so a Salesforce span only ever exists correlated to a real agent turn; +// - the whole method is wrapped in try/catch: any failure is swallowed (debug-logged only). +public with sharing class A365Telemetry { + + // Emit one execute_tool span for the current Apex unit of work. + // traceparent : inbound W3C header from the agent turn (reused, never regenerated). + // spanName : e.g. 'execute_tool A365ToolRest'. + // kind : OTLP span kind string (SERVER for inbound REST, CLIENT for outbound callout). + // attrs : flat span attributes (gen_ai.operation.name, tool name/type, etc.). + // startNs/endNs : Apex execution window in nanoseconds (epoch-based). + // ok : true => status OK, false => status ERROR. + public static void emitToolSpan( + String traceparent, String spanName, String kind, + Map attrs, Long startNs, Long endNs, Boolean ok + ) { + try { + if (!A365ObsConfig.isEnabled()) { + return; // master kill-switch off => no-op. + } + + A365Trace.TraceContext ctx = A365Trace.parseTraceparent(traceparent); + if (ctx == null) { + return; // no/invalid traceparent => no-op (never fabricate a trace). + } + + Map spanAttrs = (attrs != null) + ? new Map(attrs) + : new Map(); + String statusCode = (ok == true) ? 'OK' : 'ERROR'; + + System.enqueueJob(new A365TelemetryQueueable( + ctx.traceId, ctx.parentSpanId, A365Trace.newSpanId(), + spanName, kind, statusCode, spanAttrs, startNs, endNs)); + } catch (Exception e) { + // Fail-open: telemetry must never affect the caller's flow. + System.debug(LoggingLevel.WARN, 'A365Telemetry.emitToolSpan swallowed: ' + e.getMessage()); + } + } + + // ============================ Origination (Agentforce-native) ============================ + // + // Unlike emitToolSpan (which reuses an inbound traceparent and NEVER fabricates a trace), + // these methods make SALESFORCE the trace ORIGINATOR. There is no inbound traceparent for + // an Agentforce turn, so the traceId + root `invoke_agent` span id are derived + // deterministically from the Agentforce session id (A365Trace.*FromSeed). Every span of a + // session therefore shares one trace and nests under one reconstructable root, even when + // emitted from independent Apex transactions (each action) — no shared state. + // + // Gated on A365ObsConfig.originateEnabled() (independent of the boundary Enabled__c) and + // fully fail-open + async (Queueable), so it never affects an Agentforce turn. + + // Per-transaction dedup so a session's `invoke_agent` root is emitted at most once (L1). + @TestVisible private static Set rootsEmittedThisTxn = new Set(); + + private static final String CACHE_PARTITION = 'local.A365Obs'; + private static final Integer ROOT_DEDUP_TTL_SECONDS = 3600; + + // Emit the session's root `invoke_agent` span (deduped). Optional: most callers only need + // originateToolSpan, which synthesizes a default root automatically. + public static void originateAgentTurn( + String seed, String spanName, Map attrs, + Long startNs, Long endNs, Boolean ok + ) { + try { + if (!A365ObsConfig.originateEnabled() || String.isBlank(seed)) { + return; // disabled or no seed => no-op (cannot originate without a seed). + } + emitRoot(seed, spanName, attrs, startNs, endNs, ok); + } catch (Exception e) { + System.debug(LoggingLevel.WARN, 'A365Telemetry.originateAgentTurn swallowed: ' + e.getMessage()); + } + } + + // Emit a child `execute_tool` span nested under the session's `invoke_agent` root. Ensures + // the root exists first (deduped), so a single action call yields a complete 2-span trace. + public static void originateToolSpan( + String seed, String spanName, Map attrs, + Long startNs, Long endNs, Boolean ok + ) { + try { + if (!A365ObsConfig.originateEnabled() || String.isBlank(seed)) { + return; + } + ensureRoot(seed, startNs); + + String traceId = A365Trace.traceIdFromSeed(seed); + String rootSpanId = A365Trace.spanIdFromSeed(seed); + Map spanAttrs = (attrs != null) + ? new Map(attrs) : new Map(); + String statusCode = (ok == true) ? 'OK' : 'ERROR'; + + enqueueOriginated(traceId, rootSpanId, A365Trace.newSpanId(), + spanName, 'SERVER', statusCode, spanAttrs, startNs, endNs); + } catch (Exception e) { + System.debug(LoggingLevel.WARN, 'A365Telemetry.originateToolSpan swallowed: ' + e.getMessage()); + } + } + + // Emit the `invoke_agent` root for a seed exactly once (L1 txn set + L2 Platform Cache). + private static void emitRoot( + String seed, String spanName, Map attrs, + Long startNs, Long endNs, Boolean ok + ) { + if (!claimRoot(seed)) { + return; // already emitted for this seed (dedup). + } + String traceId = A365Trace.traceIdFromSeed(seed); + String rootSpanId = A365Trace.spanIdFromSeed(seed); + Map spanAttrs = (attrs != null) + ? new Map(attrs) : new Map(); + if (!spanAttrs.containsKey('gen_ai.operation.name')) { + spanAttrs.put('gen_ai.operation.name', 'invoke_agent'); + } + String statusCode = (ok == true) ? 'OK' : 'ERROR'; + // Root span: no parent (parentSpanId = null) — it is the top of the Agentforce trace. + enqueueOriginated(traceId, null, rootSpanId, + spanName, 'SERVER', statusCode, spanAttrs, startNs, endNs); + } + + // Synthesize a default `invoke_agent` root when a tool span originates without an explicit + // originateAgentTurn (the common in-band case where only the action emits). + private static void ensureRoot(String seed, Long refStartNs) { + Long s = (refStartNs != null) ? refStartNs : (System.now().getTime() * 1000000L); + emitRoot(seed, + 'invoke_agent ' + A365ObsConfig.agentforceServiceName(), + new Map{ 'gen_ai.operation.name' => 'invoke_agent' }, + s, s + 1000000L, true); + } + + // True if THIS caller should emit the root (not yet emitted for the seed). L1 = a + // per-transaction Set; L2 = Platform Cache (best-effort, extends dedup across + // transactions). Cache keys must be alphanumeric, so the seed is hashed into the key. + private static Boolean claimRoot(String seed) { + if (rootsEmittedThisTxn.contains(seed)) { + return false; + } + String key = 'obsRoot' + EncodingUtil.convertToHex( + Crypto.generateDigest('SHA-256', Blob.valueOf(seed))).substring(0, 24); + try { + Cache.OrgPartition partition = Cache.Org.getPartition(CACHE_PARTITION); + if (partition != null) { + if (partition.contains(key)) { + rootsEmittedThisTxn.add(seed); + return false; + } + partition.put(key, true, ROOT_DEDUP_TTL_SECONDS); + } + } catch (Exception e) { + // Platform Cache unavailable — degrade to per-transaction (L1) dedup only. + } + rootsEmittedThisTxn.add(seed); + return true; + } + + private static void enqueueOriginated( + String traceId, String parentSpanId, String spanId, + String spanName, String kind, String statusCode, + Map attrs, Long startNs, Long endNs + ) { + System.enqueueJob(new A365TelemetryQueueable( + traceId, parentSpanId, spanId, spanName, kind, statusCode, + attrs, startNs, endNs, A365ObsConfig.agentforceServiceName())); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Telemetry.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls new file mode 100644 index 00000000..78755394 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for the Agentforce-native ORIGINATION path: A365Telemetry.originate* + +// deterministic seeding + root dedup + service.name + fail-open. All callouts mocked. +// The boundary emitToolSpan ("never fabricate a trace") path is covered by A365TelemetryTest. +@IsTest +private class A365TelemetryOriginateTest { + + private static final String TENANT = '11111111-1111-1111-1111-111111111111'; + private static final String AGENT = '22222222-2222-2222-2222-222222222222'; + private static final String AF_SERVICE = 'salesforce-agentforce'; + private static final String SEED = 'sample-session-0001'; + + // Routes by endpoint: /oauth2/ => fake tokens; /otlp/ => configurable status. Collects + // EVERY ingest body so tests can assert per-span (invoke_agent vs execute_tool) shape. + private class MultiMock implements HttpCalloutMock { + public Integer tokenCalls = 0; + public Integer ingestCalls = 0; + public Integer ingestStatus = 200; + public List ingestBodies = new List(); + + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + if (req.getEndpoint().contains('/oauth2/')) { + tokenCalls++; + res.setStatusCode(200); + String tok = (Math.mod(tokenCalls, 2) == 1) ? 'FAKE_T1' : 'FAKE_OBS_TOKEN'; + res.setBody('{"access_token":"' + tok + '","expires_in":3599}'); + } else { + ingestCalls++; + ingestBodies.add(req.getBody()); + res.setStatusCode(ingestStatus); + res.setHeader('x-ms-correlation-id', 'test-corr'); + res.setBody('{"partialSuccess":{"rejectedSpans":0}}'); + } + return res; + } + } + + private static A365_Observability_Config__mdt cfg(Boolean originateEnabled) { + return new A365_Observability_Config__mdt( + Enabled__c = true, + OriginateEnabled__c = originateEnabled, + AgentforceServiceName__c = AF_SERVICE, + TenantId__c = TENANT, + AgentId__c = AGENT, + IngestBase__c = 'https://agent365.svc.cloud.microsoft', + ObsScope__c = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + FmiScope__c = 'api://AzureADTokenExchange/.default', + UseS2SEndpoint__c = true, + ServiceName__c = 'salesforce-apex'); + } + + // ---- body parsing helpers ---- + private static Map span(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map ss = (Map) ((List) rs.get('scopeSpans'))[0]; + return (Map) ((List) ss.get('spans'))[0]; + } + + private static String serviceName(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map resource = (Map) rs.get('resource'); + Map ra = (Map) resource.get('attributes'); + return (String) ra.get('service.name'); + } + + private static String opName(Map sp) { + Map attrs = (Map) sp.get('attributes'); + return (String) attrs.get('gen_ai.operation.name'); + } + + @IsTest + static void originateToolSpan_emitsRootAndChildWithDeterministicSeeding() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Long t0 = 1750000000000000000L; + Test.startTest(); + A365Telemetry.originateToolSpan(SEED, 'execute_tool A365AgentforceTool', + new Map{ 'gen_ai.operation.name' => 'execute_tool', + 'gen_ai.tool.type' => 'salesforce-apex' }, + t0, t0 + 3000000L, true); + Test.stopTest(); + + String expectedTrace = A365Trace.traceIdFromSeed(SEED); + String expectedRoot = A365Trace.spanIdFromSeed(SEED); + + System.assertEquals(2, mock.ingestCalls, 'one root + one child POSTed'); + Integer invokeAgent = 0, executeTool = 0; + for (String body : mock.ingestBodies) { + Map sp = span(body); + System.assertEquals(expectedTrace, (String) sp.get('traceId'), + 'every span shares the seed-derived traceId'); + System.assertEquals(AF_SERVICE, serviceName(body), 'origination service.name'); + String op = opName(sp); + if (op == 'invoke_agent') { + invokeAgent++; + System.assertEquals(expectedRoot, (String) sp.get('spanId'), + 'root spanId == spanIdFromSeed'); + System.assert(!sp.containsKey('parentSpanId'), 'root has no parent'); + } else if (op == 'execute_tool') { + executeTool++; + System.assertEquals(expectedRoot, (String) sp.get('parentSpanId'), + 'child nests under the seed-derived root'); + } + } + System.assertEquals(1, invokeAgent, 'exactly one invoke_agent root'); + System.assertEquals(1, executeTool, 'exactly one execute_tool child'); + } + + @IsTest + static void originateToolSpan_dedupesRootAcrossCallsSameSeed() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Long t0 = 1750000000000000000L; + Test.startTest(); + A365Telemetry.originateToolSpan(SEED, 'execute_tool A', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, t0, t0 + 1L, true); + A365Telemetry.originateToolSpan(SEED, 'execute_tool B', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, t0, t0 + 1L, true); + Test.stopTest(); + + Integer invokeAgent = 0, executeTool = 0; + for (String body : mock.ingestBodies) { + String op = opName(span(body)); + if (op == 'invoke_agent') { invokeAgent++; } + else if (op == 'execute_tool') { executeTool++; } + } + System.assertEquals(1, invokeAgent, 'invoke_agent root emitted ONCE per seed (dedup)'); + System.assertEquals(2, executeTool, 'both execute_tool children emitted'); + } + + @IsTest + static void originateAgentTurn_thenToolSpan_shareOneRoot() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Long t0 = 1750000000000000000L; + Test.startTest(); + A365Telemetry.originateAgentTurn(SEED, 'invoke_agent A365 Agentforce', + new Map{ 'gen_ai.operation.name' => 'invoke_agent' }, t0, t0 + 5000000L, true); + A365Telemetry.originateToolSpan(SEED, 'execute_tool A365AgentforceTool', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, t0 + 1L, t0 + 2L, true); + Test.stopTest(); + + Integer invokeAgent = 0; + for (String body : mock.ingestBodies) { + if (opName(span(body)) == 'invoke_agent') { invokeAgent++; } + } + System.assertEquals(1, invokeAgent, 'explicit root + tool span converge on one root'); + System.assertEquals(2, mock.ingestCalls, 'exactly two spans total'); + } + + @IsTest + static void originate_disabled_isNoOp() { + A365ObsConfig.overrideRecord = cfg(false); // OriginateEnabled__c = false + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.originateToolSpan(SEED, 'execute_tool A', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, 1L, 2L, true); + A365Telemetry.originateAgentTurn(SEED, 'invoke_agent A', null, 1L, 2L, true); + Test.stopTest(); + + System.assertEquals(0, mock.tokenCalls, 'disabled => no token call'); + System.assertEquals(0, mock.ingestCalls, 'disabled => no ingest call'); + } + + @IsTest + static void originate_blankSeed_isNoOp() { + A365ObsConfig.overrideRecord = cfg(true); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.originateToolSpan(null, 'execute_tool A', null, 1L, 2L, true); + A365Telemetry.originateToolSpan('', 'execute_tool A', null, 1L, 2L, true); + Test.stopTest(); + + System.assertEquals(0, mock.ingestCalls, 'no seed => never originate'); + } + + @IsTest + static void originate_ingestFails_failsOpen() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + mock.ingestStatus = 500; + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + // Must not throw even though ingest returns 500. + A365Telemetry.originateToolSpan(SEED, 'execute_tool A', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, 1L, 2L, false); + Test.stopTest(); + + System.assert(mock.ingestCalls >= 1, 'ingest attempted'); + System.assert(true, 'caller unaffected by ingest 500 (fail-open)'); + } + + @IsTest + static void boundaryEmitToolSpan_stillNoOpsWithoutTraceparent() { + // Regression: enabling origination must NOT change the boundary "never fabricate" rule. + A365ObsConfig.overrideRecord = cfg(true); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.emitToolSpan(null, 'execute_tool A365ToolRest', 'SERVER', null, 1L, 2L, true); + A365Telemetry.emitToolSpan('not-a-traceparent', 'execute_tool A365ToolRest', 'SERVER', + null, 1L, 2L, true); + Test.stopTest(); + + System.assertEquals(0, mock.ingestCalls, + 'boundary path still no-ops without a valid inbound traceparent'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryOriginateTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls new file mode 100644 index 00000000..85c09894 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365TelemetryQueueable — async worker that acquires the agent-bound token, builds the OTLP +// body, and POSTs the span to the Agent 365 ingest endpoint. Runs AFTER the caller's +// synchronous response, so it never affects Flow 1/Flow 2 latency or success. +// +// Best-effort by design: any failure (token, callout, non-2xx) is logged and swallowed. +// Implements Database.AllowsCallouts so the HTTP POST is permitted from the async context. +public with sharing class A365TelemetryQueueable implements Queueable, Database.AllowsCallouts { + + private final String traceId; + private final String parentSpanId; + private final String spanId; + private final String spanName; + private final String kind; + private final String statusCode; + private final Map attrs; + private final Long startNs; + private final Long endNs; + private final String serviceName; + + private static final String NC_INGEST = 'A365_Obs_Ingest'; + + // Boundary path (inbound traceparent) — uses the configured/default boundary service.name. + public A365TelemetryQueueable( + String traceId, String parentSpanId, String spanId, + String spanName, String kind, String statusCode, + Map attrs, Long startNs, Long endNs + ) { + this(traceId, parentSpanId, spanId, spanName, kind, statusCode, + attrs, startNs, endNs, null); + } + + // Origination path — carries an explicit service.name (blank/null => boundary config/default). + public A365TelemetryQueueable( + String traceId, String parentSpanId, String spanId, + String spanName, String kind, String statusCode, + Map attrs, Long startNs, Long endNs, String serviceName + ) { + this.traceId = traceId; + this.parentSpanId = parentSpanId; + this.spanId = spanId; + this.spanName = spanName; + this.kind = kind; + this.statusCode = statusCode; + this.attrs = attrs; + this.startNs = startNs; + this.endNs = endNs; + this.serviceName = serviceName; + } + + public void execute(QueueableContext context) { + try { + String tenantId = A365ObsConfig.tenantId(); + String agentId = A365ObsConfig.agentId(); + String token = A365ObsToken.getToken(); + String resolvedServiceName = String.isNotBlank(serviceName) + ? serviceName + : A365ObsConfig.serviceName(); + + String body = A365ObsSpan.buildBody( + tenantId, agentId, traceId, parentSpanId, spanId, + spanName, kind, statusCode, attrs, startNs, endNs, resolvedServiceName); + + HttpRequest req = new HttpRequest(); + req.setEndpoint('callout:' + NC_INGEST + A365ObsConfig.tracesPath()); + req.setMethod('POST'); + req.setHeader('Content-Type', 'application/json'); + req.setHeader('Authorization', 'Bearer ' + token); + req.setHeader('x-ms-tenant-id', tenantId); + req.setTimeout(20000); + req.setBody(body); + + HttpResponse res = new Http().send(req); + Integer status = res.getStatusCode(); + if (status < 200 || status >= 300) { + System.debug(LoggingLevel.WARN, 'A365Telemetry ingest non-2xx: HTTP ' + + status + ' corr=' + res.getHeader('x-ms-correlation-id') + + ' body=' + res.getBody()); + } else { + System.debug(LoggingLevel.INFO, 'A365Telemetry ingest ' + status + + ' spanId=' + spanId + ' traceId=' + traceId + + ' corr=' + res.getHeader('x-ms-correlation-id')); + } + } catch (Exception e) { + // Best-effort: never rethrow from the async worker. + System.debug(LoggingLevel.WARN, 'A365TelemetryQueueable swallowed: ' + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryQueueable.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls new file mode 100644 index 00000000..a325c15d --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for the emitter: A365Telemetry façade + A365TelemetryQueueable + the +// token cache in A365ObsToken. All callouts (token hops + ingest) are mocked. +@IsTest +private class A365TelemetryTest { + + private static final String TENANT = '11111111-1111-1111-1111-111111111111'; + private static final String AGENT = '22222222-2222-2222-2222-222222222222'; + private static final String TRACE = '4bf92f3577b34da6a3ce929d0e0e4736'; + private static final String PARENT = '00f067aa0ba902b7'; + private static final String TRACEPARENT = '00-' + TRACE + '-' + PARENT + '-01'; + // Intentionally non-default so the test proves the boundary path honors ServiceName__c. + private static final String SERVICE_NAME = 'salesforce-apex-custom'; + + // Routes by endpoint: token endpoints (/oauth2/) return fake tokens; ingest (/otlp/) + // returns a configurable status. Records counts + the last ingest request for asserts. + private class MultiMock implements HttpCalloutMock { + public Integer tokenCalls = 0; + public Integer ingestCalls = 0; + public Integer ingestStatus = 200; + public String lastIngestBody; + public String lastAuthHeader; + public String lastTenantHeader; + + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + if (req.getEndpoint().contains('/oauth2/')) { + tokenCalls++; + res.setStatusCode(200); + String tok = (tokenCalls == 1) ? 'FAKE_T1' : 'FAKE_OBS_TOKEN'; + res.setBody('{"access_token":"' + tok + '","expires_in":3599}'); + } else { + ingestCalls++; + lastIngestBody = req.getBody(); + lastAuthHeader = req.getHeader('Authorization'); + lastTenantHeader = req.getHeader('x-ms-tenant-id'); + res.setStatusCode(ingestStatus); + res.setHeader('x-ms-correlation-id', 'test-corr'); + res.setBody('{"partialSuccess":{"rejectedSpans":0}}'); + } + return res; + } + } + + private static A365_Observability_Config__mdt cfg(Boolean enabled) { + return new A365_Observability_Config__mdt( + Enabled__c = enabled, + TenantId__c = TENANT, + AgentId__c = AGENT, + IngestBase__c = 'https://agent365.svc.cloud.microsoft', + ObsScope__c = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + FmiScope__c = 'api://AzureADTokenExchange/.default', + UseS2SEndpoint__c = true, + ServiceName__c = SERVICE_NAME); + } + + private static String resourceServiceName(String body) { + Map root = (Map) JSON.deserializeUntyped(body); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map resource = (Map) rs.get('resource'); + Map attrs = (Map) resource.get('attributes'); + return (String) attrs.get('service.name'); + } + + @IsTest + static void emitToolSpan_enabled_enqueuesAndPostsSpanWithInboundTrace() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.emitToolSpan( + TRACEPARENT, 'execute_tool A365ToolRest', 'SERVER', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, + 1750000000000000000L, 1750000000123000000L, true); + Test.stopTest(); // flushes the Queueable + + System.assertEquals(2, mock.tokenCalls, 'two token hops ran'); + System.assertEquals(1, mock.ingestCalls, 'one ingest POST'); + System.assertEquals('Bearer FAKE_OBS_TOKEN', mock.lastAuthHeader, 'Bearer agent-bound token'); + System.assertEquals(TENANT, mock.lastTenantHeader, 'x-ms-tenant-id header set'); + + // The emitted span reuses the inbound traceId + parentSpanId (correlation proof). + Map root = (Map) JSON.deserializeUntyped(mock.lastIngestBody); + Map rs = (Map) ((List) root.get('resourceSpans'))[0]; + Map ss = (Map) ((List) rs.get('scopeSpans'))[0]; + Map span = (Map) ((List) ss.get('spans'))[0]; + System.assertEquals(TRACE, span.get('traceId'), 'span reuses inbound traceId'); + System.assertEquals(PARENT, span.get('parentSpanId'), 'span nests under inbound parent'); + System.assertEquals(SERVICE_NAME, resourceServiceName(mock.lastIngestBody), + 'boundary path uses configured service.name'); + } + + @IsTest + static void emitToolSpan_disabled_isNoOp() { + A365ObsConfig.overrideRecord = cfg(false); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.emitToolSpan( + TRACEPARENT, 'execute_tool A365ToolRest', 'SERVER', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, + 1L, 2L, true); + Test.stopTest(); + + System.assertEquals(0, mock.tokenCalls, 'disabled => no token call'); + System.assertEquals(0, mock.ingestCalls, 'disabled => no ingest call'); + } + + @IsTest + static void emitToolSpan_blankTraceparent_isNoOp() { + A365ObsConfig.overrideRecord = cfg(true); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + A365Telemetry.emitToolSpan( + null, 'execute_tool A365ToolRest', 'SERVER', null, 1L, 2L, true); + A365Telemetry.emitToolSpan( + 'not-a-traceparent', 'execute_tool A365ToolRest', 'SERVER', null, 1L, 2L, true); + Test.stopTest(); + + System.assertEquals(0, mock.ingestCalls, 'no/invalid traceparent => never emits'); + } + + @IsTest + static void emitToolSpan_ingestFails_failsOpen() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + mock.ingestStatus = 500; + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + // Must not throw even though ingest returns 500. + A365Telemetry.emitToolSpan( + TRACEPARENT, 'execute_tool A365ToolRest', 'SERVER', + new Map{ 'gen_ai.operation.name' => 'execute_tool' }, + 1L, 2L, false); + Test.stopTest(); + + System.assertEquals(1, mock.ingestCalls, 'ingest attempted once'); + // Reaching here without an exception is the fail-open assertion. + System.assert(true, 'caller unaffected by ingest 500'); + } + + @IsTest + static void getToken_cachesWithinTransaction_noRePost() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + MultiMock mock = new MultiMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + String first = A365ObsToken.getToken(); + String second = A365ObsToken.getToken(); + Test.stopTest(); + + System.assertEquals('FAKE_OBS_TOKEN', first, 'first acquisition returns obs token'); + System.assertEquals(first, second, 'second call returns the cached token'); + System.assertEquals(2, mock.tokenCalls, 'cache hit => no second token exchange'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TelemetryTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls new file mode 100644 index 00000000..f25f848c --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365ToolRest — Apex REST endpoint exposing Apex as an Agent 365 tool surface. +// +// Exposed automatically at: +// POST {MyDomain}/services/apexrest/a365tool +// +// Contract (matches the A365 salesforceTool): +// Request : { "prompt": "" } +// Response: { "reply": "", "echo": "", "source": "apex", "traceparent": "" } +// +// A365Callout performs the reverse direction: an outbound POST from Apex into A365 /callback. + +@RestResource(urlMapping='/a365tool/*') +global with sharing class A365ToolRest { + + // Incoming JSON body from A365. + global class A365Request { + global String prompt; + } + + // Outgoing JSON body back to A365. + global class A365Response { + global String reply; + global String echo; + global String source; + // Echo the inbound W3C trace context so the Apex leg is visibly + // correlated with the A365 agent trace (same traceId on both sides). + global String traceparent; + } + + @HttpPost + global static A365Response doPost() { + Long startNs = System.now().getTime() * 1000000L; + RestRequest req = RestContext.request; + String rawBody = (req != null && req.requestBody != null) ? req.requestBody.toString() : ''; + + // Read the inbound W3C trace context. Header names are lowercase per + // the W3C Trace Context spec; match case-insensitively to be safe. + String traceparent = getHeaderIgnoreCase(req, 'traceparent'); + String tracestate = getHeaderIgnoreCase(req, 'tracestate'); + if (String.isNotBlank(traceparent)) { + System.debug('A365 trace context received: traceparent=' + traceparent + + (String.isNotBlank(tracestate) ? ' tracestate=' + tracestate : '')); + } + + String prompt = ''; + if (String.isNotBlank(rawBody)) { + try { + A365Request parsed = (A365Request) JSON.deserialize(rawBody, A365Request.class); + if (parsed != null && parsed.prompt != null) { + prompt = parsed.prompt; + } + } catch (Exception e) { + // Treat an unparseable body as an empty prompt. + } + } + + A365Response res = new A365Response(); + res.echo = prompt; + res.source = 'apex'; + res.traceparent = traceparent; + res.reply = String.isBlank(prompt) + ? 'Hello from Salesforce Apex! (no prompt received)' + : 'Hello from Salesforce Apex! You said: "' + prompt + '"'; + + // Native Apex observability: emit this tool execution as its own A365 OTLP span, + // correlated to the agent turn via the inbound traceparent. Fail-open + async, so + // the synchronous reply above is never slowed or broken. No traceparent => no-op. + A365Telemetry.emitToolSpan( + traceparent, 'execute_tool A365ToolRest', 'SERVER', + new Map{ + 'gen_ai.operation.name' => 'execute_tool', + 'gen_ai.tool.name' => 'A365ToolRest', + 'gen_ai.tool.type' => 'salesforce-apex', + 'sf.org.id' => UserInfo.getOrganizationId(), + 'sf.prompt.len' => (prompt == null ? 0 : prompt.length()) + }, + startNs, System.now().getTime() * 1000000L, true); + + return res; + } + + // Returns the named header from the REST request, matching case-insensitively. + private static String getHeaderIgnoreCase(RestRequest req, String name) { + if (req == null || req.headers == null) { + return null; + } + for (String key : req.headers.keySet()) { + if (key != null && key.equalsIgnoreCase(name)) { + return req.headers.get(key); + } + } + return null; + } + + // Convenience GET so you can sanity-check the endpoint quickly. + @HttpGet + global static A365Response doGet() { + A365Response res = new A365Response(); + res.source = 'apex'; + res.reply = 'A365ToolRest is alive. POST { "prompt": "..." } to talk to it.'; + return res; + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls new file mode 100644 index 00000000..0fc864b2 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for A365ToolRest (REST endpoint + observability emission). + +@IsTest +private class A365ToolRestTest { + + @IsTest + static void doPost_withPrompt_echoesAndReplies() { + RestRequest req = new RestRequest(); + req.requestURI = '/services/apexrest/a365tool'; + req.httpMethod = 'POST'; + req.requestBody = Blob.valueOf('{"prompt":"hi there"}'); + RestContext.request = req; + RestContext.response = new RestResponse(); + + Test.startTest(); + A365ToolRest.A365Response res = A365ToolRest.doPost(); + Test.stopTest(); + + System.assertEquals('hi there', res.echo, 'Prompt should be echoed'); + System.assertEquals('apex', res.source, 'Source should be apex'); + System.assert(res.reply.contains('hi there'), 'Reply should include the prompt'); + } + + @IsTest + static void doPost_emptyBody_stillReplies() { + RestRequest req = new RestRequest(); + req.httpMethod = 'POST'; + req.requestBody = Blob.valueOf(''); + RestContext.request = req; + RestContext.response = new RestResponse(); + + A365ToolRest.A365Response res = A365ToolRest.doPost(); + System.assertEquals('apex', res.source); + System.assert(res.reply.contains('no prompt'), 'Should indicate no prompt received'); + } + + @IsTest + static void doGet_isAlive() { + RestContext.request = new RestRequest(); + RestContext.response = new RestResponse(); + + A365ToolRest.A365Response res = A365ToolRest.doGet(); + System.assert(res.reply.contains('alive'), 'GET should report alive'); + } + + // ---- Observability emission -------------------------------------- + + private static final String TRACE = '4bf92f3577b34da6a3ce929d0e0e4736'; + private static final String PARENT = '00f067aa0ba902b7'; + private static final String TRACEPARENT = '00-' + TRACE + '-' + PARENT + '-01'; + + // Routes token hops (/oauth2/) vs ingest (/otlp/) so the async emit Queueable can flush. + private class EmitMock implements HttpCalloutMock { + public Integer ingestCalls = 0; + public String lastIngestBody; + public HttpResponse respond(HttpRequest req) { + HttpResponse res = new HttpResponse(); + res.setHeader('Content-Type', 'application/json'); + if (req.getEndpoint().contains('/oauth2/')) { + res.setStatusCode(200); + res.setBody('{"access_token":"FAKE","expires_in":3599}'); + } else { + ingestCalls++; + lastIngestBody = req.getBody(); + res.setStatusCode(200); + res.setBody('{"partialSuccess":{"rejectedSpans":0}}'); + } + return res; + } + } + + private static A365_Observability_Config__mdt cfg(Boolean enabled) { + return new A365_Observability_Config__mdt( + Enabled__c = enabled, + TenantId__c = '11111111-1111-1111-1111-111111111111', + AgentId__c = '22222222-2222-2222-2222-222222222222', + IngestBase__c = 'https://agent365.svc.cloud.microsoft', + ObsScope__c = 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + FmiScope__c = 'api://AzureADTokenExchange/.default', + UseS2SEndpoint__c = true, + ServiceName__c = 'salesforce-apex'); + } + + private static void postWithTrace() { + RestRequest req = new RestRequest(); + req.requestURI = '/services/apexrest/a365tool'; + req.httpMethod = 'POST'; + req.addHeader('traceparent', TRACEPARENT); + req.requestBody = Blob.valueOf('{"prompt":"hi there"}'); + RestContext.request = req; + RestContext.response = new RestResponse(); + } + + @IsTest + static void doPost_emissionEnabled_contractUnchanged_andEmits() { + A365ObsConfig.overrideRecord = cfg(true); + A365ObsToken.clearCache(); + EmitMock mock = new EmitMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + postWithTrace(); + A365ToolRest.A365Response res = A365ToolRest.doPost(); + Test.stopTest(); // flush the async emit + + // Existing response contract must be identical with emission enabled. + System.assertEquals('hi there', res.echo, 'echo unchanged'); + System.assertEquals('apex', res.source, 'source unchanged'); + System.assertEquals(TRACEPARENT, res.traceparent, 'traceparent echoed'); + System.assert(res.reply.contains('hi there'), 'reply unchanged'); + + // The Salesforce span was emitted on the inbound trace. + System.assertEquals(1, mock.ingestCalls, 'one span emitted'); + System.assert(mock.lastIngestBody.contains(TRACE), 'span reuses inbound traceId'); + } + + @IsTest + static void doPost_emissionDisabled_isNoOp_contractUnchanged() { + A365ObsConfig.overrideRecord = cfg(false); + EmitMock mock = new EmitMock(); + Test.setMock(HttpCalloutMock.class, mock); + + Test.startTest(); + postWithTrace(); + A365ToolRest.A365Response res = A365ToolRest.doPost(); + Test.stopTest(); + + System.assertEquals('hi there', res.echo, 'echo unchanged when disabled'); + System.assertEquals('apex', res.source, 'source unchanged when disabled'); + System.assertEquals(0, mock.ingestCalls, 'disabled => no emission (regression guard)'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365ToolRestTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls b/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls new file mode 100644 index 00000000..a457ec87 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// A365Trace — W3C Trace Context helpers for native Apex Agent 365 observability. +// +// Parses the inbound `traceparent` header so a Salesforce-originated OTLP span can +// reuse the agent turn's traceId and nest under its `execute_tool` span, and mints +// fresh span ids. `newTraceId()` exists only for illustration (a real flow must +// never fabricate a trace — no traceparent => no span). +// +// W3C traceparent format: version(2) "-" traceId(32hex) "-" parentId(16hex) "-" flags(2) +// e.g. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 +public with sharing class A365Trace { + + // Result of parsing a traceparent header. `traceId`/`parentSpanId` are null when + // the header is missing or malformed; callers treat null as "no trace => no-op". + public class TraceContext { + public String traceId; + public String parentSpanId; + public Boolean sampled; + } + + // Parse a W3C `traceparent`. Returns null for blank/malformed input so callers can + // short-circuit to a no-op rather than inventing a trace. + public static TraceContext parseTraceparent(String header) { + if (String.isBlank(header)) { + return null; + } + List parts = header.trim().split('-'); + if (parts.size() < 4) { + return null; + } + String version = parts[0]; + String traceId = parts[1]; + String parentId = parts[2]; + String flags = parts[3]; + + if (!isHex(traceId, 32) || !isHex(parentId, 16) || !isHex(version, 2) || !isHex(flags, 2)) { + return null; + } + // An all-zero trace or parent id is invalid per the spec. + if (isAllZero(traceId) || isAllZero(parentId)) { + return null; + } + + TraceContext ctx = new TraceContext(); + ctx.traceId = traceId.toLowerCase(); + ctx.parentSpanId = parentId.toLowerCase(); + // Sampled flag is the low bit of the flags byte (low bit of the last hex digit). + Integer lowNibble = '0123456789abcdef'.indexOf(flags.substring(1, 2).toLowerCase()); + ctx.sampled = lowNibble >= 0 && Math.mod(lowNibble, 2) == 1; + return ctx; + } + + // 16 hex chars (8 random bytes) — an OTLP span id. + public static String newSpanId() { + return randomHex(8); + } + + // 32 hex chars (16 random bytes) — an OTLP trace id. Illustration-only. + public static String newTraceId() { + return randomHex(16); + } + + // --- Deterministic origination (Agentforce-native telemetry) ---------------------- + // + // When SALESFORCE originates a trace (no inbound traceparent), both the traceId and the + // root `invoke_agent` span id are derived deterministically from the Agentforce session + // id. Any emitter that knows the session id computes identical ids — so spans emitted + // from independent Apex transactions (each action, each Queueable) converge + // on ONE trace nested under ONE root, with no shared/cross-transaction state. + + // 32-hex OTLP trace id from a session seed: first 16 bytes of SHA-256(seed). Non-zero. + public static String traceIdFromSeed(String seed) { + return hexDigestFromSeed(seed, 32); + } + + // 16-hex root (`invoke_agent`) span id from a session seed: first 8 bytes of + // SHA-256(seed + ':root'). Distinct domain from traceIdFromSeed so the two never collide. + public static String spanIdFromSeed(String seed) { + return hexDigestFromSeed(seed + ':root', 16); + } + + // SHA-256(input) -> first `hexLen` lowercase hex chars, guaranteed non-zero per the W3C + // spec (re-hash with a counter suffix in the astronomically unlikely all-zero case). + private static String hexDigestFromSeed(String input, Integer hexLen) { + String s = (input == null) ? '' : input; + for (Integer attempt = 0; attempt < 5; attempt++) { + String salted = (attempt == 0) ? s : s + ':' + attempt; + Blob digest = Crypto.generateDigest('SHA-256', Blob.valueOf(salted)); + String hex = EncodingUtil.convertToHex(digest).substring(0, hexLen).toLowerCase(); + if (!isAllZero(hex)) { + return hex; + } + } + // Unreachable in practice — a deterministic, non-zero fallback of the right length. + String fallback = ''; + while (fallback.length() < hexLen) { + fallback += '1'; + } + return fallback; + } + + private static String randomHex(Integer numBytes) { + // Crypto.generateAesKey(128) yields 16 cryptographically-random bytes (32 hex). + Blob key = Crypto.generateAesKey(128); + String hex = EncodingUtil.convertToHex(key); // 32 hex chars + return hex.substring(0, numBytes * 2).toLowerCase(); + } + + private static Boolean isHex(String s, Integer len) { + if (s == null || s.length() != len) { + return false; + } + return Pattern.matches('[0-9a-fA-F]{' + len + '}', s); + } + + private static Boolean isAllZero(String s) { + for (Integer i = 0; i < s.length(); i++) { + if (s.charAt(i) != 48) { // '0' + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365Trace.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls new file mode 100644 index 00000000..fbb08b70 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for the deterministic origination seed helpers (pure — no callouts). +// These ids make spans from independent Apex transactions converge on one trace. +@IsTest +private class A365TraceSeedTest { + + @IsTest + static void traceIdFromSeed_is32HexDeterministicNonZero() { + String a = A365Trace.traceIdFromSeed('abc'); + String b = A365Trace.traceIdFromSeed('abc'); + System.assertEquals(32, a.length(), '32 hex chars'); + System.assert(Pattern.matches('[0-9a-f]{32}', a), 'lowercase hex'); + System.assertEquals(a, b, 'deterministic: same seed => same traceId'); + System.assertNotEquals('00000000000000000000000000000000', a, 'non-zero'); + } + + @IsTest + static void spanIdFromSeed_is16HexDeterministicNonZero() { + String a = A365Trace.spanIdFromSeed('abc'); + String b = A365Trace.spanIdFromSeed('abc'); + System.assertEquals(16, a.length(), '16 hex chars'); + System.assert(Pattern.matches('[0-9a-f]{16}', a), 'lowercase hex'); + System.assertEquals(a, b, 'deterministic: same seed => same spanId'); + System.assertNotEquals('0000000000000000', a, 'non-zero'); + } + + @IsTest + static void rootSpanId_differsFromTraceIdPrefix() { + String seed = 'sample-session-0001'; + String traceId = A365Trace.traceIdFromSeed(seed); + String spanId = A365Trace.spanIdFromSeed(seed); + // span derives from SHA-256(seed + ':root'), trace from SHA-256(seed) — different domains. + System.assertNotEquals(traceId.substring(0, 16), spanId, + 'rootSpanId must not equal the first 16 hex of the traceId'); + } + + @IsTest + static void differentSeeds_yieldDifferentIds() { + System.assertNotEquals( + A365Trace.traceIdFromSeed('session-A'), + A365Trace.traceIdFromSeed('session-B'), + 'different seeds => different traceIds'); + // A one-character change must fully scatter the digest (avalanche). + System.assertNotEquals( + A365Trace.traceIdFromSeed('sample-session-0001'), + A365Trace.traceIdFromSeed('sample-session-0002'), + 'tiny seed change => fully different traceId'); + System.assertNotEquals( + A365Trace.spanIdFromSeed('session-A'), + A365Trace.spanIdFromSeed('session-B'), + 'different seeds => different spanIds'); + } + + @IsTest + static void blankSeed_stillProducesValidNonZeroIds() { + // Defensive: even an empty seed yields well-formed, non-zero ids (never throws). + System.assertEquals(32, A365Trace.traceIdFromSeed('').length(), 'empty seed traceId 32 hex'); + System.assertEquals(16, A365Trace.spanIdFromSeed('').length(), 'empty seed spanId 16 hex'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TraceSeedTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls b/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls new file mode 100644 index 00000000..54899353 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Unit tests for A365Trace (pure — no callouts). +@IsTest +private class A365TraceTest { + + @IsTest + static void parseTraceparent_validHeader() { + String tp = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + A365Trace.TraceContext ctx = A365Trace.parseTraceparent(tp); + System.assertNotEquals(null, ctx, 'Expected a parsed context'); + System.assertEquals('4bf92f3577b34da6a3ce929d0e0e4736', ctx.traceId, 'traceId'); + System.assertEquals('00f067aa0ba902b7', ctx.parentSpanId, 'parentSpanId'); + System.assertEquals(true, ctx.sampled, 'sampled flag set'); + } + + @IsTest + static void parseTraceparent_notSampled() { + A365Trace.TraceContext ctx = + A365Trace.parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00'); + System.assertEquals(false, ctx.sampled, 'sampled flag clear'); + } + + @IsTest + static void parseTraceparent_blankOrMalformed_returnsNull() { + System.assertEquals(null, A365Trace.parseTraceparent(null), 'null header'); + System.assertEquals(null, A365Trace.parseTraceparent(''), 'empty header'); + System.assertEquals(null, A365Trace.parseTraceparent('not-a-traceparent'), 'too few parts'); + System.assertEquals(null, + A365Trace.parseTraceparent('00-zzzz-00f067aa0ba902b7-01'), 'non-hex traceId'); + System.assertEquals(null, + A365Trace.parseTraceparent('00-00000000000000000000000000000000-00f067aa0ba902b7-01'), + 'all-zero traceId invalid'); + } + + @IsTest + static void newSpanId_is16Hex() { + String s = A365Trace.newSpanId(); + System.assertEquals(16, s.length(), '16 hex chars'); + System.assert(Pattern.matches('[0-9a-f]{16}', s), 'lowercase hex'); + System.assertNotEquals('0000000000000000', s, 'non-zero'); + } + + @IsTest + static void newTraceId_is32Hex() { + String t = A365Trace.newTraceId(); + System.assertEquals(32, t.length(), '32 hex chars'); + System.assert(Pattern.matches('[0-9a-f]{32}', t), 'lowercase hex'); + } +} \ No newline at end of file diff --git a/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls-meta.xml b/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls-meta.xml new file mode 100644 index 00000000..998805a8 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/classes/A365TraceTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 62.0 + Active + diff --git a/salesforce/apex-observability/force-app/main/default/customMetadata/A365_Observability_Config.Default.md-meta.xml b/salesforce/apex-observability/force-app/main/default/customMetadata/A365_Observability_Config.Default.md-meta.xml new file mode 100644 index 00000000..7f633dac --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/customMetadata/A365_Observability_Config.Default.md-meta.xml @@ -0,0 +1,54 @@ + + + + + false + + Enabled__c + true + + + TenantId__c + <<TENANT_ID>> + + + AgentId__c + <<AGENT_ID>> + + + IngestBase__c + https://agent365.svc.cloud.microsoft + + + ObsScope__c + api://9b975845-388f-4429-889e-eab1ef63949c/.default + + + FmiScope__c + api://AzureADTokenExchange/.default + + + UseS2SEndpoint__c + true + + + ServiceName__c + salesforce-apex + + + AgentforceServiceName__c + salesforce-agentforce + + + OriginateEnabled__c + false + + diff --git a/salesforce/apex-observability/force-app/main/default/externalCredentials/A365_Obs_Entra.externalCredential-meta.xml b/salesforce/apex-observability/force-app/main/default/externalCredentials/A365_Obs_Entra.externalCredential-meta.xml new file mode 100644 index 00000000..cc0bde0c --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/externalCredentials/A365_Obs_Entra.externalCredential-meta.xml @@ -0,0 +1,26 @@ + + + + + Custom + + BlueprintPrincipal + NamedPrincipal + 1 + + diff --git a/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Callback.namedCredential-meta.xml b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Callback.namedCredential-meta.xml new file mode 100644 index 00000000..74ba8b21 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Callback.namedCredential-meta.xml @@ -0,0 +1,18 @@ + + + + + https://replace-with-your-tunnel-url.invalid + false + Anonymous + NoAuthentication + diff --git a/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Ingest.namedCredential-meta.xml b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Ingest.namedCredential-meta.xml new file mode 100644 index 00000000..88ff39d1 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Ingest.namedCredential-meta.xml @@ -0,0 +1,25 @@ + + + + false + false + false + + SecuredEndpoint + + Url + Url + https://agent365.svc.cloud.microsoft + + + A365_Obs_Entra + ExternalCredential + Authentication + + diff --git a/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Token.namedCredential-meta.xml b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Token.namedCredential-meta.xml new file mode 100644 index 00000000..fcb6ff07 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_Token.namedCredential-meta.xml @@ -0,0 +1,32 @@ + + + + false + true + false + + SecuredEndpoint + + Url + Url + https://login.microsoftonline.com + + + A365_Obs_Entra + ExternalCredential + Authentication + + + Authorization + HttpHeader + Basic {!$Credential.A365_Obs_Entra.BlueprintBasicAuth} + 1 + + diff --git a/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_TokenJwt.namedCredential-meta.xml b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_TokenJwt.namedCredential-meta.xml new file mode 100644 index 00000000..a49d2217 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/namedCredentials/A365_Obs_TokenJwt.namedCredential-meta.xml @@ -0,0 +1,24 @@ + + + + false + false + false + + SecuredEndpoint + + Url + Url + https://login.microsoftonline.com + + + A365_Obs_Entra + ExternalCredential + Authentication + + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/A365_Observability_Config__mdt.object-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/A365_Observability_Config__mdt.object-meta.xml new file mode 100644 index 00000000..3b5b16f9 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/A365_Observability_Config__mdt.object-meta.xml @@ -0,0 +1,6 @@ + + + + A365 Observability Config + Public + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentId__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentId__c.field-meta.xml new file mode 100644 index 00000000..7ec8c9ea --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentId__c.field-meta.xml @@ -0,0 +1,11 @@ + + + AgentId__c + Runtime agent id; must equal the obs token azp/appid. + false + + 36 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentforceServiceName__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentforceServiceName__c.field-meta.xml new file mode 100644 index 00000000..534733c7 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/AgentforceServiceName__c.field-meta.xml @@ -0,0 +1,11 @@ + + + AgentforceServiceName__c + OTLP resource service.name for Salesforce-ORIGINATED (Agentforce) traces; disambiguates this path from the boundary emitter when both share the mapped agentId. + false + + 255 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/Enabled__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/Enabled__c.field-meta.xml new file mode 100644 index 00000000..34703705 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/Enabled__c.field-meta.xml @@ -0,0 +1,8 @@ + + + Enabled__c + true + Master kill-switch. When false the emitter is a no-op (never enqueues, never calls out). + + Checkbox + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/FmiScope__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/FmiScope__c.field-meta.xml new file mode 100644 index 00000000..658b437e --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/FmiScope__c.field-meta.xml @@ -0,0 +1,11 @@ + + + FmiScope__c + Hop 1/2 FMI token-exchange scope (.default). + false + + 255 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/IngestBase__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/IngestBase__c.field-meta.xml new file mode 100644 index 00000000..d2509dd4 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/IngestBase__c.field-meta.xml @@ -0,0 +1,11 @@ + + + IngestBase__c + Base URL for the observability ingest endpoint. + false + + 255 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ObsScope__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ObsScope__c.field-meta.xml new file mode 100644 index 00000000..15ba78dd --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ObsScope__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ObsScope__c + Hop 3 scope for the agent-bound observability token (.default). + false + + 255 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/OriginateEnabled__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/OriginateEnabled__c.field-meta.xml new file mode 100644 index 00000000..32d3be09 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/OriginateEnabled__c.field-meta.xml @@ -0,0 +1,8 @@ + + + OriginateEnabled__c + false + Master switch for the Agentforce trace-ORIGINATION path (invoke_agent root + nested children), independent of the boundary Enabled__c. + + Checkbox + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ServiceName__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ServiceName__c.field-meta.xml new file mode 100644 index 00000000..e7c9ba52 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/ServiceName__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ServiceName__c + OTLP resource service.name identifying the Salesforce emitter. + false + + 255 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/TenantId__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/TenantId__c.field-meta.xml new file mode 100644 index 00000000..4d6cb3bd --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/TenantId__c.field-meta.xml @@ -0,0 +1,11 @@ + + + TenantId__c + Agent 365 tenant id (URL + x-ms-tenant-id + resource microsoft.tenant.id). + false + + 36 + false + Text + false + diff --git a/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/UseS2SEndpoint__c.field-meta.xml b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/UseS2SEndpoint__c.field-meta.xml new file mode 100644 index 00000000..b3aa4f83 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/objects/A365_Observability_Config__mdt/fields/UseS2SEndpoint__c.field-meta.xml @@ -0,0 +1,8 @@ + + + UseS2SEndpoint__c + true + true -> /observabilityService path (S2S, roles claim enforced); false -> /observability. + + Checkbox + diff --git a/salesforce/apex-observability/force-app/main/default/permissionsets/A365_Observability.permissionset-meta.xml b/salesforce/apex-observability/force-app/main/default/permissionsets/A365_Observability.permissionset-meta.xml new file mode 100644 index 00000000..70962320 --- /dev/null +++ b/salesforce/apex-observability/force-app/main/default/permissionsets/A365_Observability.permissionset-meta.xml @@ -0,0 +1,62 @@ + + + + + false + External Credential principal access + Apex class access for the invokable entry points (A365ToolRest, A365Callout, A365AgentforceTool) for A365 observability. Assign to the REST integration user and the Agentforce agent running user. + + A365AgentforceTool + true + + + A365ToolRest + true + + + A365Callout + true + + + A365Telemetry + true + + + A365TelemetryQueueable + true + + + A365Trace + true + + + A365ObsConfig + true + + + A365ObsSpan + true + + + A365ObsToken + true + + + true + A365_Obs_Entra-BlueprintPrincipal + + + UserExternalCredential + false + false + false + true + false + false + + diff --git a/salesforce/apex-observability/scripts/create-obs-config.apex b/salesforce/apex-observability/scripts/create-obs-config.apex new file mode 100644 index 00000000..b83e7d63 --- /dev/null +++ b/salesforce/apex-observability/scripts/create-obs-config.apex @@ -0,0 +1,42 @@ +// Seeds (creates/updates) the A365_Observability_Config.Default Custom Metadata record. +// +// CMDT *records* can fail to deploy via the Metadata API / sf CLI on some orgs (opaque +// UNKNOWN_EXCEPTION), so create/update them through the Apex Metadata API instead. The CMDT +// *type + fields* deploy normally with `sf project deploy start`. +// +// >>> EDIT the <<...>> placeholders below before running. <<< +// <> — your Entra tenant id +// <> — the Agent 365 agent id (the agentId in the ingest URL; token azp must match) +// +// Run: +// sf apex run --file scripts/create-obs-config.apex --target-org <> +// +// Non-secret config only. The blueprint client secret is entered in Setup on the +// A365_Obs_Entra External Credential (BlueprintBasicAuth) — never here, never in git. +Metadata.CustomMetadata cm = new Metadata.CustomMetadata(); +cm.fullName = 'A365_Observability_Config.Default'; +cm.label = 'Default'; +Map vals = new Map{ + 'Enabled__c' => true, + 'TenantId__c' => '<>', + 'AgentId__c' => '<>', + 'IngestBase__c' => 'https://agent365.svc.cloud.microsoft', + 'ObsScope__c' => 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + 'FmiScope__c' => 'api://AzureADTokenExchange/.default', + 'UseS2SEndpoint__c' => true, + 'ServiceName__c' => 'salesforce-apex', + // Origination (Agentforce-native) fields — set OriginateEnabled__c = true only if you + // build the optional Agentforce agent (see ../agent/README.md). + 'AgentforceServiceName__c' => 'salesforce-agentforce', + 'OriginateEnabled__c' => false +}; +for (String f : vals.keySet()) { + Metadata.CustomMetadataValue v = new Metadata.CustomMetadataValue(); + v.field = f; + v.value = vals.get(f); + cm.values.add(v); +} +Metadata.DeployContainer container = new Metadata.DeployContainer(); +container.addMetadata(cm); +Id jobId = Metadata.Operations.enqueueDeployment(container, null); +System.debug('A365_Observability_Config.Default deploy enqueued: ' + jobId); diff --git a/salesforce/apex-observability/scripts/verify-span.apex b/salesforce/apex-observability/scripts/verify-span.apex new file mode 100644 index 00000000..dd123160 --- /dev/null +++ b/salesforce/apex-observability/scripts/verify-span.apex @@ -0,0 +1,32 @@ +// Smoke test: emit a single Agent 365 SERVER span via the public A365Telemetry façade and +// confirm the full pipeline (FMI 3-hop token -> OTLP ingest) end to end. +// +// Prerequisites (see ../README.md): +// - core deployed; A365_Observability_Config.Default seeded (Enabled__c = true) +// - BlueprintBasicAuth secret entered on the A365_Obs_Entra External Credential +// - the A365_Observability permission set assigned to the running user +// +// Run, then tail the logs to see the async ingest result: +// sf apex run --file scripts/verify-span.apex --target-org <> +// sf apex tail log --target-org <> +// +// A synthetic-but-valid W3C traceparent (00-<32 hex>-<16 hex>-01). In a real turn this comes +// from the inbound agent request header; here we fabricate one purely to exercise the pipeline. +String traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; +Long nowNs = System.now().getTime() * 1000000L; + +A365Telemetry.emitToolSpan( + traceparent, + 'execute_tool verify-span', + 'SERVER', + new Map{ + 'gen_ai.operation.name' => 'execute_tool', + 'gen_ai.tool.name' => 'verify-span', + 'gen_ai.tool.type' => 'salesforce-apex', + 'sf.org.id' => UserInfo.getOrganizationId() + }, + nowNs, + nowNs + 1000000L, + true); + +System.debug('verify-span enqueued — tail the log for the async ingest HTTP status.'); diff --git a/salesforce/apex-observability/sfdx-project.json b/salesforce/apex-observability/sfdx-project.json new file mode 100644 index 00000000..0e08619a --- /dev/null +++ b/salesforce/apex-observability/sfdx-project.json @@ -0,0 +1,12 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "a365-salesforce-apex-observability", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "62.0" +} diff --git a/salesforce/docs/design.md b/salesforce/docs/design.md new file mode 100644 index 00000000..ad903636 --- /dev/null +++ b/salesforce/docs/design.md @@ -0,0 +1,44 @@ +# Salesforce Samples — Design Guidelines + +Salesforce samples in this repository demonstrate how **Apex** and **Agentforce** integrate with the +Microsoft Agent 365 platform — primarily **observability** (emitting Agent 365 OTLP telemetry from +Salesforce) and the agent ↔ Salesforce tool boundary. + +Unlike the `dotnet/`, `python/`, and `nodejs/` samples (which host an agent process), Salesforce +samples are **SFDX projects** deployed into a Salesforce org. There is no long-running server to host; +the "agent-facing" surface is Apex (REST endpoints and `@InvocableMethod` actions) plus an optional +Agentforce agent built in the org. + +## Conventions + +- **Project shape** — each sample is a standard SFDX project: `force-app/main/default/...`, + `sfdx-project.json`, `.forceignore`. Deploy with `sf project deploy start`. +- **Naming** — Apex has no namespaces, so classes use a short `A365` prefix as a de-facto namespace. +- **Copyright headers** — every `.cls` begins with the Microsoft copyright header (`//` comments). + Metadata XML/JSON files are configuration and are exempt. +- **Secrets** — never in git. Secret values are entered post-deploy on a Salesforce **External + Credential** (Setup). Non-secret runtime config lives in **Custom Metadata** records, seeded via an + Execute-Anonymous script when the org cannot deploy CMDT records directly. +- **Telemetry is fail-open** — observability code must never affect the business response; wrap emit + paths so errors are swallowed (debug-logged) and run the actual export asynchronously (`Queueable`). +- **Auth** — MSAL is unavailable in Apex, so S2S OAuth (including the FMI agent-bound token chain) is + hand-rolled as raw form-POST callouts via Named Credentials, with the secret injected from the + External Credential as a merge field. + +## Testing + +- Use `HttpCalloutMock` to mock the token + ingest hops; assert OTLP body shape, trace correlation, + the disabled no-op, and fail-open behavior. +- Deploy/test with `sf project deploy start --test-level RunLocalTests` (enforces 75% aggregate org + coverage). Validate without persisting using `--dry-run`. + +## Documentation + +Each Salesforce sample includes a `README.md` (what it demonstrates, prerequisites, deploy/secret/ +config steps, testing, troubleshooting) and a `docs/design.md` (architecture, token model, wire +shape, dependency graph). + +For a specific sample's concrete architecture — token model, OTLP wire shape, dependency graph — see +that sample's `docs/design.md` (e.g. [`apex-observability/docs/design.md`](../apex-observability/docs/design.md)), +which is the single source of truth for implementation detail; this file stays at the cross-sample +convention level.