Learn how to build a production-grade cross-environment analytics system using the Power Platform Admin APIs to extract flow run telemetry, compute sliding-window failure rates, and detect SLA breaches across Dev, Test, and Production — before your customers do. This expert-level lesson covers service principal auth, paginated run history queries, P95 duration calculations, and breach alert routing with full code examples.

Imagine it's 7:45 AM on a Monday and your operations lead is forwarding you a complaint from a key customer: their order confirmation emails stopped arriving Friday evening. You open the Power Platform Admin Center, navigate to your production environment, and start clicking through individual flow run histories one by one. Twenty minutes later, you've confirmed that yes, your order-processing flow has a 34% failure rate since Friday at 6:12 PM, but you have no idea how long it's been degraded, whether the same issue is showing in test, or what the blast radius across your other production flows looks like. You've just experienced the monitoring gap that plagues most enterprise Power Automate deployments.
The Power Platform Admin APIs give you a programmatic path out of that gap. Using the Flow Analytics and Environment Management APIs — combined with a well-designed aggregation layer — you can build a system that continuously extracts run telemetry from every environment, computes failure rates over sliding windows, and surfaces SLA breaches before your operations team hears about them from customers. This isn't about adding a dashboard for its own sake; it's about giving your enterprise the same observability posture that mature DevOps teams expect from any production workload.
By the end of this lesson, you'll have implemented a cross-environment analytics pipeline using the Power Platform Admin APIs from within Power Automate itself — a meta-pattern that uses the platform to monitor the platform. You'll know how to authenticate as a service principal, page through flow run histories at scale, aggregate failure metrics, detect SLA violations, and route alerts to the right teams.
What you'll learn:
You should be comfortable with the following before working through this lesson:
body(), outputs(), and compose actions in Power Automate.Before writing a single action, you need to understand what APIs you're actually working with and how they relate to each other. The Power Platform ecosystem surfaces several overlapping API surfaces, and conflating them leads to authentication errors, missing data, and hitting the wrong endpoints.
The Power Platform Admin API lives at https://api.bap.microsoft.com and handles environment management, capacity, and policy operations. This is the API you use to enumerate environments across a tenant.
The Flow Service API lives at https://api.flow.microsoft.com and handles flow-level operations: listing flows within an environment, retrieving flow definitions, and — critically for us — querying flow run history. The base URL you'll use most is:
https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/{environmentId}/flows/{flowId}/runs
These two APIs use different scopes for authentication. The Admin API uses https://service.flow.microsoft.com/.default or https://api.bap.microsoft.com/.default depending on the operation, while the Flow Service API consistently uses https://service.flow.microsoft.com/.default. In practice, the scope https://service.flow.microsoft.com/.default covers both for most read operations, which simplifies your token acquisition.
Warning
Do not confuse the Power Platform Admin API with the Dataverse Web API (https://{org}.api.crm.dynamics.com/api/data/v9.x). The Dataverse API stores solution metadata and entity records, but it does not expose flow run telemetry. Flow run history lives exclusively in the Flow Service API. Trying to query run history from Dataverse will return nothing useful and waste significant debugging time.
Your monitoring flow needs to run unattended against multiple environments, which means service principal authentication. The setup involves three steps:
Step 1: Register an Azure AD application. In Azure Portal, go to Azure Active Directory → App Registrations → New Registration. Name it something like pp-analytics-svc. Note the Application (client) ID and tenant ID. Create a client secret under Certificates & Secrets and store it securely — ideally in Azure Key Vault, as described in Integrating Power Automate with Azure Key Vault and Managed Identities: Complete Guide to Secrets Management and Zero-Trust Authentication.
Step 2: Assign Power Platform admin permissions. In the Power Platform Admin Center, go to Settings → Users → Add service principal, and add your app registration. Assign it the Power Platform Administrator or Dynamics 365 Administrator role. Without this, the API calls to enumerate environments will return 403 Forbidden or empty result sets even with a valid token.
Step 3: Grant API permissions. In Azure AD → App Registrations → your app → API Permissions, add Dynamics CRM → user_impersonation as an application permission (not delegated). Grant admin consent. This is the permission that allows your service principal to call the Flow Service API as a background application without a user sign-in context.
The token acquisition call looks like this, and you'll make it at the start of your monitoring flow:
POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={clientId}
&client_secret={clientSecret}
&scope=https://service.flow.microsoft.com/.default
In Power Automate, build this as an HTTP action with method POST, the URL above, and the body as form-encoded. Store the access_token from the response in a variable — you'll pass it as Bearer {token} in the Authorization header of every subsequent call.
The first analytical challenge is that you don't want to hardcode environment IDs. Environments get created, decommissioned, and renamed. Your monitoring system should discover environments dynamically and tag them by type (Development, Test, Production).
Call the environment list endpoint:
GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments?api-version=2021-04-01&$filter=properties/environmentSku eq 'Production'
Authorization: Bearer {token}
The $filter parameter supports filtering by environmentSku, which accepts values Production, Sandbox, Trial, and Default. For a monitoring system targeting Dev, Test, and Production, you'll typically call this endpoint twice — once for Production and once for Sandbox (which covers both Dev and Test in most enterprise naming conventions).
The response payload structure looks like this:
{
"value": [
{
"id": "/providers/Microsoft.BusinessAppPlatform/environments/Default-{tenantId}",
"name": "{environmentId}",
"properties": {
"displayName": "Contoso Production",
"environmentSku": "Production",
"createdTime": "2023-01-15T09:00:00Z",
"states": {
"runtime": {
"id": "Ready"
}
}
}
}
]
}
The name field gives you the GUID you need for subsequent Flow API calls. The displayName is what you'll use in alert messages so humans understand which environment is affected.
Note
The environment list API does paginate when you have more than 100 environments in a tenant. While most enterprises don't exceed this limit, the response includes a nextLink property when more pages exist. Build your enumeration logic to follow nextLink continuations from the start — retrofitting pagination later is messier than handling it upfront.
In Power Automate, use an Apply to Each over the value array and collect the environment name and display name into a custom array using an Append to Array action. This becomes your environment manifest for the rest of the monitoring pipeline.
With your environment list in hand, the next layer of the hierarchy is enumerating the flows that exist within each environment. For a cross-environment analytics system, you'll query all flows, not just your own:
GET https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/{environmentId}/flows?api-version=2016-11-01&$top=250
Authorization: Bearer {token}
The scopes/admin path segment is critical — without it, you'll only see flows owned by the service principal itself, not all flows in the environment. The $top=250 parameter sets your page size, and the maximum is 250.
The response includes each flow's ID, display name, state, and owner:
{
"value": [
{
"name": "{flowId}",
"properties": {
"displayName": "Order Confirmation Email",
"state": "Started",
"createdTime": "2023-06-01T10:00:00Z",
"lastModifiedTime": "2024-01-10T14:22:00Z",
"creator": {
"objectId": "{userId}",
"displayName": "Sarah Chen"
},
"flowTriggerUri": "https://prod-xx.westus.logic.azure.com:443/workflows/..."
}
}
]
}
For large environments, this call will paginate. The nextLink property in the response body gives you the full URL to the next page, including continuation token. Build a Do Until loop that calls the endpoint, appends results to your flows array, checks for the existence of nextLink, and breaks when it's absent.
Tip
You don't necessarily want to monitor every flow in every environment. Add a naming convention filter or tag-based filter early in your pipeline. For example, filter to flows whose displayName starts with [MON] or whose solution membership matches your production solution names. Querying run history for 500 flows every 15 minutes will burn through your API quotas fast. Be selective, and you'll have headroom for high-frequency polling on your critical paths.
This is the core of the analytics system. For each flow you're monitoring, you'll query its run history within a time window and extract status, duration, and error information.
GET https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/{environmentId}/flows/{flowId}/runs?api-version=2016-11-01&$top=100&$filter=startTime ge {isoStartTime} and startTime le {isoEndTime}
Authorization: Bearer {token}
The time filter uses ISO 8601 format. In Power Automate expressions, build your window boundaries like this:
utcNow() → current end time
addHours(utcNow(), -1) → start time for a 1-hour window
formatDateTime(addHours(utcNow(), -1), 'yyyy-MM-ddTHH:mm:ssZ') → formatted for the filter
A representative run record looks like this:
{
"name": "{runId}",
"properties": {
"startTime": "2024-03-15T06:14:22.1234567Z",
"endTime": "2024-03-15T06:14:45.9876543Z",
"status": "Failed",
"code": "ActionFailed",
"error": {
"code": "ConnectionFailed",
"message": "The connection to the SQL server timed out after 30 seconds."
},
"trigger": {
"name": "Recurrence",
"startTime": "2024-03-15T06:14:22.0000000Z",
"endTime": "2024-03-15T06:14:22.1234567Z",
"scheduledTime": "2024-03-15T06:14:00.0000000Z",
"status": "Succeeded"
}
}
}
The fields you care about for analytics are:
| Field | Purpose |
|---|---|
properties.status |
Succeeded, Failed, Cancelled, Running |
properties.startTime |
When the run began (use for window bucketing) |
properties.endTime |
When it completed (null if still running) |
properties.code |
High-level failure classification |
properties.error.code |
Specific error type |
properties.error.message |
Human-readable error detail |
Calculating run duration: The API does not give you a pre-computed duration field. You compute it yourself:
ticks(properties.endTime) - ticks(properties.startTime)
Ticks are 100-nanosecond intervals. Divide by 10,000,000 to get seconds, or 60,000,000,000 to get minutes. For a flow with an SLA of "must complete within 5 minutes," you'd flag any run where the duration exceeds 300 seconds.
Raw run data isn't useful on its own. You need to aggregate it into failure rate metrics that tell you something actionable. The standard metric is failure rate percentage over a sliding window — typically 1 hour, 6 hours, and 24 hours.
For a given flow over a given window, the calculation is:
failureRate = (failedCount / totalCount) * 100
Where totalCount excludes runs with status Running (they haven't completed) and optionally Cancelled (depending on your alerting philosophy — more on that below).
In Power Automate, after collecting all run records for a flow, use a Filter Array action to split them:
Filter for failed runs:
@equals(item()?['properties']?['status'], 'Failed')
Filter for completed runs (succeeded or failed):
@or(
equals(item()?['properties']?['status'], 'Succeeded'),
equals(item()?['properties']?['status'], 'Failed')
)
Then use the length() expression:
div(
mul(length(body('Filter_Failed_Runs')), 100),
length(body('Filter_Completed_Runs'))
)
Warning
Watch for division-by-zero when a flow has zero completed runs in the window. This happens legitimately for infrequently triggered flows — a flow that runs daily will have zero runs in a 1-hour window for most of the day. Guard with a condition: if(greater(length(completedRuns), 0), div(...), 0). Without this guard, your monitoring flow itself will fail, which is a spectacularly ironic failure mode.
Beyond the aggregate failure rate, you want to know why flows are failing. Group failed runs by their error.code field to identify the most common failure types. In Power Automate, use a Select action to extract just the error codes from your failed runs array, then use a Compose action to build a frequency map using nested expressions.
For a production system, I recommend shipping this raw error distribution to Application Insights or a Dataverse table rather than trying to process it entirely within the flow. The Building a Power Automate Monitoring and Alerting System: Detecting Failed Flows, Notifying Owners, and Logging Telemetry to Azure Application Insights lesson covers the Application Insights integration pattern in detail.
This is a design decision worth making deliberately. Cancelled runs can be either:
The properties.code field helps disambiguate. User cancellations show code: Cancelled. Timeout-induced cancellations often show code: FlowRunTimedOut or code: ThrottledException. Build your filter to include timeout/throttle cancellations in your failure count.
A failure rate metric only becomes an SLA breach when it crosses a threshold that you've defined. This is where your monitoring system transitions from descriptive analytics to actionable alerting.
Design your SLA definitions as a configuration object — not hardcoded in flow logic. Store them in Dataverse or as environment variables so they can be updated without touching flow definitions. A well-structured SLA config looks like this:
[
{
"flowNamePattern": "Order*",
"environment": "Production",
"maxFailureRatePct": 5,
"windowMinutes": 60,
"maxDurationSeconds": 120,
"alertChannels": ["teams-ops-channel", "email-sre-team"],
"severity": "Critical"
},
{
"flowNamePattern": "Report*",
"environment": "Production",
"maxFailureRatePct": 15,
"windowMinutes": 1440,
"maxDurationSeconds": 600,
"alertChannels": ["email-data-team"],
"severity": "Warning"
},
{
"flowNamePattern": "*",
"environment": "Sandbox",
"maxFailureRatePct": 40,
"windowMinutes": 60,
"maxDurationSeconds": 300,
"alertChannels": ["email-dev-team"],
"severity": "Info"
}
]
Pattern matching in Power Automate uses the startsWith(), endsWith(), and contains() expressions. For wildcard prefix matching like Order*, use startsWith(flowName, 'Order'). For catch-all patterns, every flow matches * so you apply it only when no more specific pattern matched.
For each flow you've computed metrics for, iterate through the SLA config and find the first matching rule (most specific first). Then evaluate two conditions:
Failure rate breach:
failureRatePct > slaConfig.maxFailureRatePct
Duration breach (P95): Rather than flagging every slow run, compute the 95th percentile duration across all completed runs in the window. If P95 exceeds your SLA duration threshold, that's a breach. Computing P95 in Power Automate requires sorting the duration array and picking the item at index floor(count * 0.95):
// Sort durations ascending
// Pick item at: floor(length(durationsArray) * 0.95)
item()?['durations'][int(floor(mul(length(variables('durations')), 0.95)))]
Key insight
The choice to alert on P95 duration rather than mean duration is deliberate. Mean duration is heavily influenced by a small number of very fast runs. P95 tells you what the worst 5% of your users are experiencing, which is what your SLA should be protecting. If your SLA says "complete within 2 minutes," and 6% of runs take 4 minutes, you're breaching — even if the average is 45 seconds.
When a breach is detected, build a breach record:
{
"flowId": "{flowId}",
"flowName": "Order Confirmation Email",
"environmentName": "Contoso Production",
"environmentType": "Production",
"windowMinutes": 60,
"totalRuns": 847,
"failedRuns": 54,
"failureRatePct": 6.4,
"slaThresholdPct": 5,
"p95DurationSeconds": 143,
"slaDurationSeconds": 120,
"breachType": "FailureRateAndDuration",
"mostCommonError": "ConnectionFailed",
"detectedAt": "2024-03-15T07:00:00Z",
"severity": "Critical"
}
Collect all breach records into an array across all environments. This is your cross-environment SLA breach report.
Now let's put the full architecture together. The monitoring flow is itself a scheduled cloud flow — set it to run every 15 minutes for production SLA tracking, or every hour for sandbox environments.
Note
Because this monitoring flow is itself a Power Automate flow, it's subject to the same run limits and quotas as the flows it's monitoring. For large tenants with hundreds of flows, consider whether a 15-minute schedule with 500 HTTP calls per run is sustainable within your licensing tier. The Capacity, API Request Limits, and Entitlements in Power Platform: A Complete Guide for Enterprise Architects article gives you the numbers to assess this. For very large deployments, consider using Azure Functions or Azure Logic Apps for the aggregation layer instead, with Power Automate only handling alert routing.
Here's the high-level flow structure:
Phase 1: Bootstrap
allBreaches, allMetricsPhase 2: Environment Discovery
5. GET environments (Production) → collect to productionEnvironments
6. GET environments (Sandbox) → collect to sandboxEnvironments
7. Merge into allEnvironments with type tag
Phase 3: Flow Discovery and Metric Collection (Apply to Each: environment)
8. GET flows for environment → paginated loop
9. Filter to monitored flows (by name pattern or solution membership)
10. Apply to Each: flow
- Determine applicable SLA config
- GET run history for window → paginated loop
- Compute failure rate, P95 duration, error distribution
- Evaluate against SLA thresholds
- If breach: append to allBreaches
- Append metrics to allMetrics
Phase 4: Persistence
11. Write allMetrics to Dataverse (upsert by flowId + windowStart)
12. Write new allBreaches to Dataverse breach log
Phase 5: Alerting
13. For each breach in allBreaches:
- Look up alert channel configuration
- POST to Teams webhook or send email
- Create incident in ServiceNow/Jira if severity = Critical
The key architectural decision in Phase 3 is concurrency. If you have 10 environments and 50 monitored flows per environment, you're making 500+ HTTP calls per monitoring run. With default sequential execution in Apply to Each, this could take 10-15 minutes — meaning by the time you detect a breach, it may already be 15 minutes old.
Enable parallelism on the outer Apply to Each (environments) by setting the concurrency control to run up to 10 branches simultaneously. Within each environment, also enable parallelism on the Apply to Each over flows, with a concurrency of 5-10 depending on your API quota headroom. As covered in Implementing Parallel Branching and Concurrency Control in Power Automate to Maximize Throughput and Prevent Race Conditions, enabling parallelism means you must use Append to Array Variable actions carefully — they're thread-safe in Power Automate, but only when you're appending items rather than reading and rewriting the same variable.
The run history endpoint returns a maximum of 250 records per page. For a heavily-used production flow, you could easily have thousands of runs in a 1-hour window. You must paginate.
The pagination pattern for the Flow runs API uses a nextLink property in the response body. Build this as a Do Until loop:
// Initialization
Set variable: runHistoryUrl = 'https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/{environmentId}/flows/{flowId}/runs?api-version=2016-11-01&$top=250&$filter=...'
Set variable: allRuns = []
Set variable: hasMore = true
// Do Until: hasMore = false
HTTP GET {runHistoryUrl} with Bearer token
Append to Array: allRuns += body('Get_Runs')['value']
Condition: Does body('Get_Runs') contain 'nextLink'?
Yes: Set runHistoryUrl = body('Get_Runs')['nextLink']
No: Set hasMore = false
Warning
The nextLink URL returned by the Flow API already includes the continuation token and all other query parameters. Do not reconstruct the URL — use it verbatim. If you try to append your original filter parameters to the nextLink, you'll get malformed request errors or silently lose data.
For a 1-hour window on a high-volume flow, you might pull 2,000-5,000 run records. At that scale, the Apply to Each loops for aggregation become slow. Consider using Select to project only the fields you need immediately after collecting all pages, before entering the aggregation logic:
// Select action: project run records to minimal shape
{
"status": "@{item()?['properties']?['status']}",
"startTime": "@{item()?['properties']?['startTime']}",
"endTime": "@{item()?['properties']?['endTime']}",
"errorCode": "@{item()?['properties']?['error']?['code']}"
}
Working with a smaller payload per item makes the subsequent Filter and Apply to Each actions noticeably faster.
A point-in-time breach alert is useful, but trending data is transformative. When you can say "this flow's failure rate has been trending upward for the past 6 hours and just crossed your 5% threshold," your incident response team has much better context than "it's failing right now."
Design a Dataverse table called cr_FlowMetricSnapshot with these columns:
| Column | Type | Purpose |
|---|---|---|
cr_FlowId |
Text | Flow GUID |
cr_FlowName |
Text | Display name |
cr_EnvironmentId |
Text | Environment GUID |
cr_EnvironmentType |
Choice | Production / Test / Dev |
cr_WindowStart |
DateTime | Start of measurement window |
cr_WindowEnd |
DateTime | End of measurement window |
cr_TotalRuns |
Integer | Total completed runs in window |
cr_FailedRuns |
Integer | Failed runs in window |
cr_FailureRatePct |
Decimal | Computed failure rate |
cr_P95DurationSeconds |
Decimal | P95 completion time |
cr_MostCommonError |
Text | Most frequent error code |
cr_SlaBreached |
Yes/No | Whether SLA was breached |
Upsert records using the cr_FlowId + cr_WindowStart combination as your natural key. This prevents duplicate snapshot records if your monitoring flow retries.
For trending analysis, query this table with a Power BI dataset or use Dataverse queries directly from your monitoring flow to compute the rate of change in failure rate. If failureRatePct has increased by more than 2 percentage points across the last three consecutive snapshots, that's a trending alert worth escalating even if the absolute rate hasn't crossed your threshold.
A breach record sitting in Dataverse doesn't help anyone until it reaches the people who can act on it. Design your alert routing around the breach severity and the environment type.
Teams notification format (using Adaptive Cards via incoming webhook):
{
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "🔴 SLA Breach Detected",
"weight": "bolder",
"size": "large"
},
{
"type": "FactSet",
"facts": [
{"title": "Flow", "value": "Order Confirmation Email"},
{"title": "Environment", "value": "Contoso Production"},
{"title": "Failure Rate", "value": "6.4% (SLA: 5%)"},
{"title": "P95 Duration", "value": "143s (SLA: 120s)"},
{"title": "Top Error", "value": "ConnectionFailed"},
{"title": "Failed Runs (1h)", "value": "54 of 847"},
{"title": "Detected At", "value": "2024-03-15 07:00 UTC"}
]
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "View Flow in Admin Center",
"url": "https://admin.powerplatform.microsoft.com/environments/{envId}/flows/{flowId}"
}
]
}
The direct deep-link to the flow in Admin Center is a small touch that dramatically reduces the time-to-investigate. Build the URL by composing environmentId and flowId from your breach record.
For Critical breaches in Production, trigger a secondary flow that creates a ServiceNow incident or Jira ticket via their respective APIs. Pass the full breach JSON as the ticket description so the on-call engineer has everything they need without having to query the monitoring system separately. The Auditing and Governing Power Automate at Scale: Flow Ownership, Policies, Usage Analytics, and Automated Compliance Reporting with CoE Toolkit article discusses how to integrate monitoring outputs with governance workflows.
Tip
Implement breach deduplication before you send alerts. If a flow is breaching continuously, you don't want to send a Teams notification every 15 minutes for the same ongoing incident. Store the last alert timestamp per flow in Dataverse, and suppress repeat alerts within a cooldown window (e.g., 2 hours). Only re-alert if the breach has resolved and re-triggered, or if severity escalates.
The unique value of a cross-environment analytics system is the ability to compare the same logical flow across its lifecycle environments. A failure rate spike in Production that's also present in Test suggests the issue was introduced in a recent deployment. A failure that's isolated to Production points toward infrastructure or configuration divergence.
Build a flow fingerprint — a normalized identifier that maps the same logical flow across environments. The simplest approach is using the flow's display name, normalized (lowercased, whitespace stripped). For solution-aware flows, the solution component name is a better key since it's explicitly preserved during solution import.
Your cross-environment comparison query:
allMetrics
.groupBy(flowFingerprint)
.pivot(environmentType → failureRatePct)
This gives you a view like:
| Flow Name | Dev Failure % | Test Failure % | Prod Failure % |
|---|---|---|---|
| Order Confirmation Email | 2.1% | 2.4% | 6.4% |
| Inventory Sync | 0% | 0% | 0% |
| Report Generator | 8.3% | 8.1% | 8.6% |
The Order Confirmation Email row immediately tells you: failure rate jumps from ~2% in Dev/Test to 6.4% in Production. The root cause is likely Production-specific — a connection reference pointing to a different SQL endpoint, a DLP policy difference, or a production infrastructure issue. The Report Generator row tells a different story: consistently high failure across all environments, which means it's a code-level issue that needs fixing in Dev and promoting up.
Let's put the full pipeline together in a concrete exercise. You'll build a minimal but functional version of the cross-environment analytics system targeting two environments (one Sandbox, one Production) and two monitored flows.
Setup (do this before building the flow):
admin.powerplatform.microsoft.com/environments/{id}/...).make.powerautomate.com/environments/{envId}/flows/{flowId}).Build the monitoring flow:
Step 1: Trigger and bootstrap. Create a new Scheduled Cloud Flow in a solution. Set the interval to every 1 hour. Add two Compose actions to define your environment and flow configuration:
// Compose: environments
[
{"id": "{sandboxEnvId}", "name": "Contoso Dev", "type": "Sandbox"},
{"id": "{productionEnvId}", "name": "Contoso Production", "type": "Production"}
]
// Compose: monitoredFlows
[
{"flowId": "{flow1Id}", "environmentId": "{sandboxEnvId}", "displayName": "Order Processor"},
{"flowId": "{flow2Id}", "environmentId": "{productionEnvId}", "displayName": "Order Processor"}
]
Step 2: Acquire access token. Add an HTTP action to POST to the token endpoint:
Method: POST
URI: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Headers: Content-Type: application/x-www-form-urlencoded
Body: grant_type=client_credentials&client_id={clientId}&client_secret={clientSecret}&scope=https://service.flow.microsoft.com/.default
Use a Parse JSON action on the response body with schema { "access_token": {"type": "string"} }. Store access_token in a string variable called bearerToken.
Step 3: Query run history. For each flow in your monitored flows compose, make an HTTP GET to the runs endpoint with a 1-hour window filter. Use formatDateTime(addHours(utcNow(), -1), 'yyyy-MM-ddTHH:mm:ssZ') for the start time.
Step 4: Aggregate. Add a Filter Array for failed runs. Add Compose actions to compute failedCount, totalCount, and failureRatePct. For this exercise, skip P95 and use mean duration instead.
Step 5: Evaluate SLA. Add a Condition action: if failureRatePct is greater than 5, send yourself a Teams notification with the flow name, environment, failure rate, and top error code extracted from the first failed run.
Step 6: Run and validate. Manually trigger the flow and verify it completes successfully. Check the run history of one of your monitored flows in the Admin Center to confirm the counts in your notification match what you see manually.
Extend the exercise: Once the basic pipeline works, add the cross-environment comparison logic. After collecting metrics for both flows, use a Compose action to output a comparison object showing failure rate by environment. If the Production rate exceeds Sandbox rate by more than 3 percentage points, add a note to the Teams alert indicating possible environment-specific configuration issue.
Mistake: Using the wrong API scope for token acquisition. If you use https://management.azure.com/.default instead of https://service.flow.microsoft.com/.default, your token will work for Azure Resource Manager calls but fail with 401 on the Flow Service API. The error message is usually generic — "The audience is invalid" or simply 401 Unauthorized. Always double-check your scope.
Mistake: Forgetting the scopes/admin path segment. Without it, the flow list endpoint returns only flows owned by the service principal (usually zero), and the error is simply an empty value array — no error code, no 403, just nothing. This is a silent failure that's easy to miss if you're not asserting on expected result counts.
Mistake: Hardcoding time zone offsets in the window filter. The Flow runs API expects UTC timestamps with the Z suffix. If you build your window boundaries from a user's local time or a Power Automate expression that returns local time, your filter will silently pull runs from the wrong window. Always use utcNow() and formatDateTime(..., 'yyyy-MM-ddTHH:mm:ssZ').
Mistake: Not handling the case where endTime is null. Runs with status Running have no endTime. If you try to compute duration on a running run, ticks(null) will throw an expression evaluation error and fail your flow. Always guard duration calculations with a null check: if(empty(item()?['properties']?['endTime']), 0, ...).
Mistake: Alert storms from missing deduplication. The first time you deploy a monitoring flow without breach deduplication, you'll know it. If your production environment has ten breaching flows and your monitoring runs every 15 minutes, you'll send 40 Teams notifications per hour per flow. Implement the cooldown pattern immediately.
Troubleshooting: 429 Too Many Requests from the Flow API. The Admin API has rate limits, and hitting them is a real risk in large tenants. When you receive a 429, the response includes a Retry-After header indicating how many seconds to wait. Build a retry policy on your HTTP actions (configure it in the action settings panel: Retry Policy → Fixed or Exponential, 4 retries, 60 second interval). This handles transient 429s automatically. For structural quota problems, review Implementing Power Automate Flow Run Quotas and Request Rate Monitoring with Power Platform Admin APIs.
Troubleshooting: Monitoring flow itself failing. Set a run-after exception handler on your monitoring flow — a parallel branch that catches flow-level failures and sends an alert to a dedicated "monitoring system health" Teams channel. The irony of your monitoring system failing silently is avoidable with two extra actions.
You now have a complete blueprint for cross-environment flow analytics using the Power Platform Admin APIs. The architecture you've built does something that the out-of-box Power Platform Admin Center cannot: it computes aggregate failure rates over configurable windows, compares metrics across environment tiers, evaluates those metrics against per-flow SLA definitions, and routes breach alerts with enough context for immediate action.
The core patterns to carry forward:
https://service.flow.microsoft.com/.default scope unlocks admin-level telemetry across all environments without requiring user sign-innextLink traversal give you complete data, not just the last 25 runs visible in the UIWhere to go next:
Extend your telemetry pipeline by adding correlation IDs to your monitored flows so that run-level traces map to end-to-end business transactions. The lesson on Implementing Correlation IDs and End-to-End Distributed Tracing Across Power Automate Cloud Flows, Child Flows, and External Systems for Production Observability gives you the specific patterns.
Consider whether your high-volume aggregation logic belongs in Power Automate at all. For tenants with hundreds of monitored flows, the aggregation tier is a better fit for Azure Functions or Azure Data Factory, with Power Automate handling only alert routing and human workflow. That boundary keeps your Power Automate licensing costs predictable and your aggregation latency low.
Finally, connect your SLA breach data to your ALM pipeline. If a flow is consistently breaching in Sandbox before it reaches Production, that's a gate condition — your CI/CD pipeline should block promotion until the failure rate stabilizes. That kind of closed-loop governance is what separates truly mature Power Automate operations from organizations that are perpetually reactive.