
Review Report
- 42 installs
- 836 repo stars
- Updated July 29, 2026
- data-goblin/power-bi-agentic-development
Audit Power BI reports for quality, usage, and effectiveness, producing a prioritized list of findings and concrete recommendations.
About
Structured evaluation of Power BI reports to assess whether they are effective, well-built, and actually used, outputting prioritized findings with recommendations. A developer or consultant uses it to run a report health check, usage analysis, or quality audit.
- Assesses report effectiveness, build quality, and usage
- Outputs a prioritized list of findings and fixes
Review Report by the numbers
- 42 all-time installs (skills.sh)
- Ranked #989 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/data-goblin/power-bi-agentic-development --skill review-reportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 42 |
|---|---|
| repo stars | ★ 836 |
| Last updated | July 29, 2026 |
| Repository | data-goblin/power-bi-agentic-development ↗ |
What it does
Audit Power BI reports for quality, usage, and effectiveness, producing a prioritized list of findings and concrete recommendations.
Files
Reviewing Power BI Reports
Structured evaluation of Power BI reports to produce actionable feedback for developers and consultants. A report review assesses whether a report is effective, well-built, and actually being used. The output is a prioritized list of findings with concrete recommendations.
Note that the skill works on one of three scenarios:
1. Report under development: In this scenario, the focus is more on the report content, structure, organization, and performance based on accurately gathered requirements. 2. Report in testing: In this scenario, the focus might incorporate user feedback or check basic information about the deployed report in Power BI / Fabric. 3. Report in use: This is the ideal scenario, where the focus is usage; the ultimate definition of success is whether the report is being used; what percentage of the people who have access to the report have accessed it in the last 28 days, and how much? Bad reports aren't used, or have declining usage.
In scenario 2-3 you may still provide feedback on the report content / structure, but prioritizing other things first.
When to Use
Activate when conducting a report review, audit, or health check. Common triggers:
- Reviewing report quality before a release or handoff
- Assessing whether existing reports are worth maintaining
- Identifying optimization opportunities across a workspace
- Evaluating report design and data presentation effectiveness
- Investigating report performance issues
Review Dimensions
A comprehensive report review evaluates six dimensions. Not every review needs all six -- scope to what the user needs.
1. Usage and Adoption
The most objective signal of report value. A report that nobody views is a maintenance liability regardless of its design quality.
Retrieve usage data with the scripts in scripts/:
# Workspace overview (views, rank, page views, load times)
python3 scripts/get_report_usage.py -w <workspace-id>
# Add Tier 3 cross-workspace last-visited timestamps via the undocumented DataHub V2 API
# Useful for the "is this report being used at all" question without tenant admin role
python3 scripts/get_report_usage.py -w <workspace-id> --include-datahub
# Single report deep-dive (daily views, per-viewer breakdown, page views by day)
python3 scripts/get_report_detail.py -w <workspace-id> -r <report-id>
# Distribution audit (who has access, through what channels)
python3 scripts/get_report_distribution.py -w <workspace-id> -r <report-id>Filtering viewers: Exclude non-consumer users from adoption metrics. Service principals (type App), report developers, and IT / support personnel inflate viewer counts and distort reach. See references/usage-metrics.md for identification heuristics and references/distribution.md for resolving security groups and distribution lists via the Microsoft Graph API.
Evaluate usage signals:
- Audience reach is the most important metric: what percentage of users with access have actually viewed the report in the last 7, 28, and 60 days? See
references/distribution.mdfor how to calculate reach and what the numbers mean - View trends: Is viewership stable, growing, or declining? Use the rolling 7D average (see
references/usage-metrics.md) - Page view distribution: Are views concentrated on one page or spread across the report? Before calling a page unused, check whether it is a tooltip/drillthrough target (no direct views expected) and confirm reachability via
pbir pages list - Last visited: When was the report last accessed by anyone? Tier 1 (Admin Activity Events, 30-day rolling, admin role required) is the official path. The Tier 3 DataHub V2
lastVisitedTimeUTCfield (--include-datahub) is the non-admin cross-workspace fallback; flag to the user it's undocumented and can break - Load times: Are P50 and P90 load times acceptable for the audience? See
references/performance.mdfor interpretation
Do not use arbitrary thresholds for what constitutes "healthy" or "concerning"; these depend entirely on the report's audience, purpose, and lifecycle stage. A report for 3 analysts has different expectations than one for 300 executives. Match the review window to the report's cadence before drawing conclusions; see references/usage-interpretation.md for common misreads of the modern Usage Metrics report and the retire/keep/redesign decision framework.
Subscriptions are not views. Email subscriptions deliver report snapshots without generating view events. Check admin/reports/{id}/subscriptions (requires Fabric Admin) for active subscribers. A report with 0 views but active subscriptions is being consumed passively.
Use rolling 7-day averages for view trends. Raw daily counts are noisy. Compare the current 7D average to the prior 7D to identify trajectory. See references/usage-metrics.md for methodology.
Key insight: Reports with 0 views are not necessarily bad. They may be new, seasonal, consumed via subscriptions, or used via embedded scenarios not captured in telemetry. Cross-reference with last-visited timestamps. Prefer the Tier 1 admin Activity Events feed where admin access is available; fall back to the Tier 3 DataHub V2 path when it is not, while flagging that it is undocumented.
Permissions: Tier 1 (WABI) needs any workspace role. Tier 2 (model) needs workspace Contributor+. Distribution and subscription checks need Fabric Admin (tenant-level). See references/usage-metrics.md for the full permission matrix.
For additional context on the usage metrics dataset schema and available tables, see usage-metrics-dataset/.
2. Design and Layout
Evaluate the visual design and information architecture. Consult the pbi-report-design skill for detailed guidelines. Reference: Data Goblins Report Checklist.
Checklist:
- [ ] Page titles present and descriptive
- [ ] Visual spacing consistent (equal gaps between visuals and margins)
- [ ] Detail gradient followed (KPIs top-left, detail bottom-right)
- [ ] Color usage intentional and accessible (no gratuitous color, no red/green for colorblind users)
- [ ] Font family, size, and formatting consistent throughout
- [ ] Visual count reasonable (loosely 12-15 max per page, depends on complexity)
- [ ] No empty visuals (all visuals have field bindings)
- [ ] Theme applied (not default Power BI theme)
- [ ] Chart axes begin at zero (unless intentional)
- [ ] Default sort configured on all visuals
- [ ] Visual objects labelled clearly in the selection pane (grouped with descriptive names)
- [ ] Mobile layout provided if relevant audience
- [ ] Visual headers configured (disable when drill-down/through not needed)
- [ ] Interactions configured (cross-filtering/highlighting intentional, not default)
- [ ] Slicer 'Apply' buttons considered for performance-sensitive pages
- [ ] Synchronized slicers where required across pages
3. Data Model Binding
Evaluate the connection between the report and its underlying semantic model.
Checklist:
- [ ] Report connects to a published semantic model ("thin report") rather than embedding its own ("thick report")
- [ ] All field bindings resolve to existing model columns/measures
- [ ] Extension measures (thin report measures) used sparingly and only for report-specific logic
- [ ] No broken or orphaned field references
- [ ] Appropriate use of measures vs. columns in visuals (aggregation context)
- [ ] Separate filters are not active on visual-level
4. Performance
Assess report load time and visual complexity. Run the performance audit script:
python3 scripts/performance_audit.py -w <workspace-id> -r <report-id>See references/performance.md for percentile interpretation, DAX query inference from visual field bindings, and common anti-patterns.
Key indicators: P50 and P90 load times, visual count per page, extension measure count. Visual count is a proxy; query cost per visual is the real driver. See references/performance-audit.md for the full cost model, how to interpret a Performance Analyzer export, DirectQuery report-layer tuning levers, and the interaction/navigation audit. Do not apply rigid visual-count thresholds without reading the cost model first.
5. Report Metadata and Governance
Assess the report's governance posture. See references/report-metadata.md.
Checklist:
- [ ] Thin report (connected to published model, not embedded thick model)
- [ ] Endorsement status appropriate for its audience (Certified for production, Promoted for team use)
- [ ] Sensitivity label applied if tenant policy requires it
- [ ] Part of a deployment pipeline if in a production workspace
- [ ] Distribution via workspace app or org app (not direct links or publish-to-web)
- [ ] Access granted via security groups, not individual users
- [ ] View-only access for consumers (Viewer role), edit access only for developers
- [ ] Export-to-Excel patterns reviewed for data governance risks (see
references/export-to-excel.md)
6. Accessibility, Standards, and Documentation
Evaluate whether the report meets accessibility, organizational standards, and documentation requirements.
Accessibility:
Most accessibility checks can be run statically against PBIR files; only focus traversal and screen-reader readout need a live report. See references/accessibility-audit.md for the full procedure: geometry/alignment audit, tab-order reconciliation, static pass (alt text, decorative items, color-only encoding), SVG-measure checks, script visual checks, and mobile readiness.
Summary checklist:
- [ ] Alt text present on all data visuals (including SVG-measure hosts and script visuals)
- [ ] Decorative items removed from tab sequence (
tabOrder = -1) - [ ] Tab order matches geometric reading pattern (top-to-bottom, left-to-right)
- [ ] No color-only encoding (paired with shape, glyph, or text)
- [ ] Color contrast meets WCAG 2.1 AA (4.5:1 text, 3:1 UI elements)
- [ ] Font sizes legible (min 9pt data, 12pt labels)
- [ ] Mobile layout present on consumption-intended pages
- [ ] Live keyboard Tab traversal verified (cannot be confirmed from files)
Standards:
- [ ] Sensitivity labels applied if required by governance policy
- [ ] Naming conventions followed (report name, page names, visual titles)
- [ ] Link provided for users to report issues or submit feedback
- [ ] Filter combinations tested thoroughly
Documentation (for handover/production):
- [ ] Purpose statement: what business questions does the report answer?
- [ ] Intended audience and user segments identified
- [ ] Atypical features documented (visual-level filters, hidden slicers, bookmarks, custom visuals)
- [ ] Support personnel and procedures identified
- [ ] Training/adoption materials available for business users
Review Workflow
Step 1: Scope
Clarify what the user wants reviewed. Ask:
- Single report or workspace-wide audit?
- Which dimensions matter most? (usage, design, performance, all?)
- Is there a specific concern prompting the review?
- Where should findings be documented? (scratchpad, agent-docs, obsidian notebook, wiki, etc.)
Step 1a: Determine Scope and Access
Ask the user:
- Do they have access to the underlying semantic model?
- Are they the developer of both the report and model, or only one?
If the semantic model is in scope, use the semantic-model skill in parallel. Many report issues (slow visuals, (Blank) values, missing fields) originate in the model. See references/best-practices.md for model symptoms that surface in reports.
Step 1b: Determine Report Lifecycle Stage
If the report is local-only or not yet published, ask the user:
"Is this a report in development which doesn't yet have users, a report in testing with a subset of the user audience, or a report that's already distributed and should be seeing active usage and value generation?"
This determines which dimensions are applicable:
| Stage | Usage data? | What to review |
|---|---|---|
| Development | No | Design, data model binding, performance, accessibility, structure |
| Testing | Partial | All of the above + verify testers are actually testing (views from test audience) |
| Production | Yes | All dimensions including full usage, distribution, and export analysis |
Remind the user: a report's success lives and dies on whether it is being used and delivering business value. Design, performance, and structure can be reviewed proactively, but usage data is the only objective measure of whether the report is working. Good requirements gathering helps achieve adoption, but it can never be guaranteed.
If the report is local-only, ask where the published version is (or will be). Usage metrics require a published report in the Power BI service.
Step 2: Gather Data
Run the usage script for quantitative data. Export or inspect the report definition for qualitative assessment.
Step 3: Evaluate
Walk through each relevant dimension using the checklists above. Score each finding by severity:
- Critical: Broken functionality, security risk, or completely unused report consuming capacity
- High: Performance issues impacting users, major design violations, missing data bindings
- Medium: Design inconsistencies, moderate performance concerns, partial accessibility gaps
- Low: Minor polish items, style preferences, optimization opportunities
Step 4: Report Findings
Present findings as a structured summary. Lead with the most impactful findings.
Format:
REPORT REVIEW: <Report Name>
===============================
USAGE SIGNAL
Views (30d): 47 | Viewers: 8 | Rank: #3/22
Top pages: Overview (60%), Detail (30%), Trends (10%)
Load time P50: 3.2s | P90: 7.1s
CRITICAL
- [Performance] P90 load time exceeds 7s due to 14 visuals on Overview page
HIGH
- [Design] No page titles on 2 of 3 pages
- [Binding] 3 visuals have broken field references
MEDIUM
- [Design] Inconsistent margins (24px left, 32px right)
- [Accessibility] Missing alt text on 5 data visuals
LOW
- [Design] Default theme applied; consider custom theme
- [Standards] Report name uses spaces instead of hyphensPrerequisites
Before running usage scripts, ensure:
- Azure CLI authenticated: run az login if needed-fabCLI authenticated: runfab auth loginif needed (for distribution script)
- Pythonrequestspackage:uv pip install requests
References
- `references/usage-metrics.md` -- Full documentation of all usage data APIs (official and undocumented)
- `references/usage-interpretation.md` -- Reading modern Usage Metrics correctly; retire/keep/redesign decision framework
- `references/distribution.md` -- All report access paths and how to audit them
- `scripts/get_report_usage.py` -- Workspace-level usage overview
- `scripts/get_report_detail.py` -- Single report deep-dive (daily, per-viewer, per-page)
- `scripts/get_report_distribution.py` -- Distribution audit (ACL, apps, publish-to-web)
- `scripts/performance_audit.py` -- Load times + visual complexity analysis
- `references/performance.md` -- Percentile interpretation, DAX query inference from visual metadata
- `references/performance-audit.md` -- Query cost model, Performance Analyzer export, DirectQuery tuning, interaction/navigation audit
- `references/accessibility-audit.md` -- Alignment/tab-order audit, static accessibility pass, SVG/script/mobile checks
- `references/report-metadata.md` -- Thick/thin, endorsement, sensitivity, pipeline, model properties
- `references/export-to-excel.md` -- Export activity analysis, data governance implications
- `references/best-practices.md` -- Data visualization principles, chart selection, color, interaction design
- `usage-metrics-dataset/` -- Exported Usage Metrics dataset (TMDL schema + report definition)
Related Skills
- `semantic-model` -- Companion skill for semantic model design and review (run in parallel when model is in scope)
- `pbi-report-design` -- Detailed report design guidelines and layout rules
- `modifying-theme-json` -- Theme authoring, compliance auditing, formatting promotion
- `deneb-visuals`, `python-visuals`, `r-visuals`, `svg-visuals` -- Visual-specific review criteria (now in the custom-visuals plugin; add with
claude plugin install custom-visuals@power-bi-agentic-development)
Accessibility Audit
File-level checks that can be run from the terminal, plus a short residue pass that requires a live report. Keep the two sets separate; never assert "passes accessibility" from a file scan alone.
Alignment and Gutter Consistency
A pure-geometry audit from the PBIR position blocks; run before any screenshot.
pbir visuals properties "Sales.Report/Overview.Page/*" -s position --jsonBuild four sorted lists from the output: left x, right x+width, top y, bottom y+height. Any cluster of near-but-not-identical values is a misalignment candidate; snap to the modal value. Compute gutters as gaps between a row's right edges and the next left edge; flag rows where the spread exceeds a couple of pixels. Verify that distinct x-edges on row 1 match row 2 (continuous vertical lines).
Round survivors to the grid unit with pbir set <path>.position.x <value>, then re-validate.
Notes:
basicShape, decorativeimage/textbox, andactionButtonparticipate in the grid for alignment checks but should be excluded from visual-density counts- A
visualGroupchild'spositionis relative to the group, which has its ownposition+ScaleMode; resolve group offsets before comparing or you get phantom misalignment - Geometry alignment is necessary but not sufficient; pair with the tab-order check below
Tab Order vs Reading Pattern
Spatial reading order (F/Z) and tabOrder (keyboard-tab and screen-reader announce sequence) are set independently; Power BI does not derive one from the other. A flawless Z-pattern can still ship a scrambled announced order.
pbir visuals properties "Sales.Report/Overview.Page/*" -s tabOrder --jsonReconstruct intended order from geometry: sort by (y_band, x) where y_band buckets visuals into rows. Where a visual's tabOrder rank diverges from its geometric rank, the announced order fights the layout; flag it.
Key conventions:
- A negative
tabOrder(e.g.-1) removes an item from the tab sequence; Desktop writes this via the Selection pane. A meaningful visual with a negative value becomes unreachable by keyboard; flag unexpected negatives - Decorative visuals should be removed from the tab sequence (
tabOrder = -1); a decorative item still in sequence is a 1.3.2 risk - Zero
tabOrderset on every visual means the author never made the order intentional; that is a 1.3.2 risk, not a hard failure position.z(stacking order) is unrelated totabOrder; never use z to fix the reading sequence- Grouped visuals announce within their group
Static Accessibility Pass
The following checks produce findings from visual.json alone:
- Missing alt text: each data visual (exclude
basicShape, decorativeimage/textbox,actionButton) must have non-emptygeneral.altText; find gaps withpbir visuals format "MyPage/*" -p general.altText - Decorative items in tab sequence: any
basicShape, brandimage, or divider withtabOrder >= 0should be-1 - Tab-order coherence: dump
(name, visualType, x, y, tabOrder); flag where ascendingtabOrderdoes not track top-to-bottom, left-to-right - Color-only encoding: a series/CF color with no paired data label, icon, or marker shape (WCAG 1.4.1)
- Unreachable meaningful visual: any data visual with a negative
tabOrder
Tag each finding [Accessibility] with its WCAG SC: 1.1.1 alt, 1.3.2/2.4.3 reading/focus, 1.4.1 color-only, 2.1.1 keyboard.
SVG-Driven Visuals
Detect SVG measures by grepping reportExtensions.json expressions for image/svg+xml. Three checks not covered by the standard accessibility pass:
- Each table/matrix/card/image whose bound measure returns an svg+xml URI must have non-empty
general.altText(static or measure-driven) AND the SVG-encoded numbers must also appear as a readable adjacent column. Severity high for primary KPIs, medium for decorative micro-charts - Flag SVG measures whose only context-varying element is a
fill/strokecolor with no accompanying shape, glyph, or text run (color-only encoding inside the SVG) - Flag SVG measures that recompute base aggregations inside the string-builder (CONCATENATEX over fact rows rather than pre-aggregated values), and matrix value-axis SVGs lacking a
HASONEVALUE/ISINSCOPEtotal guard (these are both an accessibility concern and a performance one)
The Desktop Bridge screenshot does not confirm accessibility; alt text is invisible in a screenshot.
Script Visuals (Python/R)
pythonVisual and scriptVisual have additional accessibility exposure because they render a static PNG with no data table fallback and no per-cell alt text slot.
pbir visuals query --type pythonVisual --json
pbir visuals query --type scriptVisual --jsonFor each script visual found:
- Confirm the host visual has non-empty
general.altText - Flag the
Publish to webdistribution path: Python/R visuals render empty there; this is a blocking finding for public embeds - Flag the
app-owns-dataembed path: R/Python visuals do not render in app-owns-data scenarios (not a future risk; broken now) - Note the "Show visuals as tables" fallback (
Ctrl+Shift+F11) does not include script visuals; the PNG is the only output
Also audit the script literal:
pbir get 'Page/MyScriptVisual.Visual' objects.scriptAn unreviewed third-party script is a security finding.
Mobile Readiness
A page can be flawless on web and unusable in portrait. These are entirely file-based signals:
- Has a phone layout: count
mobile.jsonfiles under the page'svisuals/; zero means the page falls back to rotated landscape on phones. Flag consumption-intended pages with none - Coverage vs over-stuffing: ratio of mobile-placed to total visuals; near-zero on a key page is a gap, near-1.0 is the over-faithful-miniature anti-pattern
- Stale placement: a
visual.jsonedited long after itsmobile.json(heuristic; flag for manual review) - Orphan records: a
mobile.jsonwhose siblingvisual.jsonis gone (a blocking state;pbir validatecatches it) - Mobile-only override sprawl: large
objectsblocks inmobile.jsonthat duplicate rather than delta desktop formatting
Tie severity to intent: a back-of-house detail page with no phone layout is fine; a headline KPI page visible in an org app is high severity. Mobile-optimized views render only in the native iOS/Android apps; a browser always shows the landscape layout, so you cannot verify portrait by browser screenshot.
Must-Render Residue
These cannot be confirmed from files; state them as not-file-verifiable:
- Tab/Shift+Tab focus traversal between regions (
Ctrl+F6) - Screen-reader readout via "Show data" (
Alt+Shift+F11) and "Show visuals as tables" (Ctrl+Shift+F11) card/slicer/smart-narrative/Q&A/Key-Influencers/paginated are excluded from "Show visuals as tables"; the table fallback does not cover them
Screenshots do not capture focus; the only check for traversal is a live keyboard pass.
Report Design Best Practices
Data visualization and report design principles for evaluating Power BI reports. Based on the Data Goblins Report Checklist.
Reviewing Philosophy
An LLM or agent cannot assert that a report "looks good" or "is good." Provide evaluation and suggestions for possible improvements, but aim to spar with the user... steer them in the right direction and augment them with the appropriate skills to make good things.
Best practices are defaults, not mandates. They are recognized standards providing a helpful starting point for most scenarios. They are not optimizations. Optimizations are situation-specific techniques. When flagging deviations from best practices, present them as observations and suggestions, not failures. Ask the user whether the deviation is intentional. Do not over-extrapolate from one scenario to another or make assumptions about the audience or purpose of the report.
For performance recommendations specifically: do not recommend optimizations without evidence. If suggesting a change, recommend that the user test it rigorously, or offer to test it yourself by inferring queries from visual fields and querying the semantic model with a trace. Test multiple times when comparing; a single test yields misleading conclusions. Revert to simpler approaches if testing shows no meaningful improvement... avoid unnecessary complexity
When viewing a report (via Chrome MCP, Playwright, devtools, or screenshot), keep a keen eye for anomalies like (Blank) values, repeating values, or query errors, but do not use a simple screenshot or interaction to assert design competence. Always confirm font size readability with the user -- Claude tends to underestimate whether fonts are large enough.
Understanding Business Context
Before evaluating design, understand the report's purpose:
1. What business process is being reported on? Read the report definition and the definition of its underlying semantic model to understand this. 2. What questions is it trying to answer? Is it descriptive, exploratory, or prescriptive? 3. Who is the intended audience? What are they expected to do with this data? Ask the user. 4. Is the report redundant? Are there other, related reports tackling similar questions or reporting similar data? Check with the fabric-cli skill. 5. Is it replacing an existing solution? If migrating from another tool/platform, is that existing solution being used? Ask the user.
You can hypothesize, but don't assume purpose from the visual layout or metadata alone.
The 3/30/300 Rule
| Time | What the user should grasp | Design implication |
|---|---|---|
| 3 seconds | The main message or headline insight | KPIs, cards, and titles at the top-left |
| 30 seconds | Context and supporting trends | Charts and comparisons in the middle |
| 300 seconds | Granular detail for exploration | Tables, matrices, and drill-through in the bottom-right |
Layout
- Charts should be consistent in their sizes
- Equal spacing between charts on a page and between charts and the page edges
- Most important and simplest information in the top-left; more detailed/dense information in the bottom-right
- Charts should have sufficient space to render, while not too many charts are displayed at once
- Only 2-3 simple slicers on the page; the rest should be in the filter pane
- Report pages should have a clear and descriptive title
- Include data freshness information (e.g. a card showing last refresh datetime)
- Identical slicers on different pages should be synchronized
Chart Selection
Match the visualization type to the analytical question being asked.
Visual Vocabulary
| Question | Recommended Visual | Avoid |
|---|---|---|
| What is the current value? | Card, KPI | Gauge (hard to read), pie chart |
| How does this compare? | Bar chart (horizontal preferred -- axis labels more readable) | Pie chart (>3 slices), donut |
| What is the trend over time? | Line chart, area chart | Bar chart (implies discrete, not continuous) |
| What is the composition? | Stacked bar, treemap | Pie chart (>5 slices) |
| What is the distribution? | Histogram, box plot (via Deneb/SVG), swarm | Default visuals (limited support) |
| What is the relationship? | Scatter plot | Line chart (implies sequence) |
| How do two periods compare? | Slope chart, dumbbell plot (via Deneb/SVG) | Side-by-side bars (harder to compare) |
| What is the part-to-whole? | Stacked bar (100%), waterfall | Pie chart (poor perceptual accuracy) |
| How does this vary by category? | Small multiples, matrix with conditional formatting | Single cluttered chart |
Charting Best Practices
- Visuals should provide sufficient meaning and context to be interpretable and should leverage pre-attentive attributes
- Axes should start at 0 except for line charts focused on overall trend. If a line chart does not start at 0, note this in the subtitle
- Horizontal bars are better than vertical bars in most cases since axis labels are more readable
- Avoid pie and donut charts. If the user wants one, suggest a donut with smaller radius slices
- Tables and matrices should not have too many columns or try to show too much at once. Sufficient padding (4 is usually enough), not too much conditional formatting. Values should have sufficient rounding to avoid showing too much detail, but users may prefer full unformatted numbers
- Visuals should have sort order applied: typically descending by the key value field, ascending if negative numbers require more attention, or categorical for date fields (quarter, month, year, workdays MTD, etc.)
- Data labels: Use instead of axis ticks if feasible for bar charts. If data labels are shown, the axis may not be needed (but keep the axis title for context)
Anti-Patterns
- Pie charts with >5 slices: Comparing categories is better done with a simple bar chart or alternative chart type
- Dual-axis charts: Misleading when scales differ; use small multiples or separate visuals instead
- Gauges: Take up space, show one value poorly; use a card with trend instead
- Default visual interactions: Avoid deviating from default interactions unless there's an explicit reason to do so
- Too many custom/macgyvered visuals: SVG, R, Python, or heavily customized core visuals using atypical properties to achieve unique results increase maintenance burden disproportionately
Formatting and Conditional Formatting
- Formatting should not be decoration; aim for an optimally high information-to-ink ratio
- Formatting should be functional and consistent between visuals, pages, and related reports. This should be reflected in a good, common theme reused across reports
- Formatting is more about what to take away than what to add
- Static formatting should ideally be in the theme, not in bespoke visual overrides. When the theme changes, it should propagate to all downstream visuals. Some visual overrides are inevitable and necessary, but the theme should carry the baseline
- If data labels are shown, the axis may not be needed -- but keep the axis title so it's clear what is being measured
Color Usage
Color is a data encoding channel, not decoration. It must be used as a resource to steer or direct attention to important or actionable areas.
Principles
1. Muted/pastel palettes: Soft, desaturated colors reduce visual noise. Reserve saturated/bright colors for emphasis and alerts 2. Intentional encoding: Every color should mean something. Color should draw attention to important and actionable elements; it should not be overused 3. Semantic consistency: If "Sales" measures are blue on one page, it must be blue on every page 4. Sentiment colors: Reds/oranges/yellows for bad and green/blue for good should not be used for categories -- only for sentiment encoding 5. Accessible palettes: Test for red-green colorblindness. Prefer blue-orange or blue-red diverging scales. Do not rely on color alone 6. Limit the palette: 5-7 colors maximum
Font and Text
- Font family: Use Segoe UI or Segoe UI Semibold, or other default fonts built-in to Power BI. Custom fonts are not guaranteed to render on all devices
- Consistency: Font family, size, weight, and color should be consistent throughout. Variation should signal hierarchy, not randomness
- Minimum sizes: 9pt for data values, 12pt for labels and titles. Confirm with the user whether sizes are large enough -- always check this
- Data labels: Use sparingly. Dense data labels create clutter
Container Formatting
- Containers should have basic, simple formatting unless there is a specific reason to deviate
- Charts can have titles but avoid redundancy or titles/subtitles taking up too much space
Interaction Design
Slicers and Filters
- Maximum of 2-3 slicers on the page; the rest should be in the filter pane
- Apply buttons: Consider enabling on reports with performance issues
- Synchronize slicers across pages to prevent conflicting filter states
- Visual-level filters are invisible to users and a common source of confusion. Document them explicitly. Prefer page-level or report-level filters
- Default slicer selections: Set sensible defaults so the report loads with useful data
Cross-Filtering and Interactions
- Set/modified interactions can be useful but are generally avoided to prevent confusion with users and other developers
- Test filter combinations beyond the defaults and most common scenarios
- Try to interact with the report to verify cross-filtering works as expected
Navigation and Organization
- Title/landing page: Establish context before data
- Bookmarks: Use minimally. Sometimes necessary for dynamically showing/hiding charts or navigation, but generally better to avoid. Bookmark states are fragile
- Organizational pages: Provide FAQ information about the report, data, model, where to get help, how to read certain charts, calculations, etc. If not a full page, a (?) button with a link can serve the same purpose
Organizational Best Practices
- Extension measures: Can exist but should only contain logic specific to this report. If beneficial for multiple reports, push it into the semantic model
- Visual calculations: Can exist but should only contain logic specific to a single visual. If beneficial for multiple visuals, move to an extension measure or model measure
- Alt text: Good practice but very rare in practice
- Hidden visuals / hidden slicers: Can cause confusion with users and other developers, especially hidden slicers
Semantic Model Considerations
Many report issues originate in the underlying semantic model. Ask the user:
1. Do they have access to the underlying semantic model? 2. Are they the developer of both the report and model, or only one of them?
If the model is in scope, use the semantic-model skill in parallel. The following are model-related issues that surface as report symptoms:
- (Blank) values from referential integrity violations (missing keys) or incorrect relationships
- Repeating/inflated values from many-to-many or bidirectional relationships
- Slow visuals caused by expensive DAX measures, large model size, or missing aggregations
- Missing fields referenced by visual bindings (renamed or removed columns/measures)
- RLS not behaving correctly for different user contexts
- Refresh frequency not matching business needs
- Unused columns/tables inflating model size
These are documented in detail in the semantic-model skill. For the report review, note these as symptoms and flag them for model-level investigation.
Design for Agents
- Use annotations to provide documentation or descriptive instructions about reports, pages, or visuals
- Include instruction or memory files in the .Report folder that pertain to the report
- Focus on providing key learnings for next time or implicit information from the business or the analysts perspective
Report Distribution and Access Paths
Reference for all the ways a user can gain access to a Power BI report, how to check each path programmatically, and best practices for distribution.
Distribution Best Practices
Preferred Distribution Channels
| Channel | Recommendation | Rationale |
|---|---|---|
| Workspace App / Org App | Preferred | Centrally managed, audience-scoped, version-controlled. Users get a curated experience without workspace access. Assume that Org Apps will eventually replace Workspace Apps, but note that Org apps are Fabric-only |
| Direct workspace role (Viewer) | Acceptable for small teams | Simple but tightly coupled to workspace. Users see all workspace content. |
| Direct report sharing link | Avoid for ongoing distribution | Hard to audit, no central management, easily forgotten. Acceptable for one-off sharing. |
| Publish-to-web | Avoid unless intentionally public | No authentication. Anyone with the URL can view the report. Security risk for internal data. |
| Org-wide sharing link | Avoid unless truly org-wide | Overly broad access. Use security groups instead. |
Use Security Groups
Distribute access via Entra ID (Azure AD) security groups rather than individual users:
- Central management: IT or data team manages group membership in one place
- Scalability: Adding/removing users doesn't require touching Power BI
- Auditability: Group membership is logged in Entra ID
- Consistency: Same group can grant access across workspaces, apps, and reports
When reviewing a report's distribution, flag individual user assignments and recommend consolidation into security groups.
View vs Edit Access
Surface the distinction between view-only and edit-capable users:
| Access Level | Workspace Roles | Direct Share Roles | Risk |
|---|---|---|---|
| View only | Viewer | Read, ReadReshare | Low -- consumers |
| Can edit | Contributor, Member, Admin | ReadWrite, Owner | Higher -- can modify report definition |
When presenting distribution findings, separate view-only users from edit-capable users. A report with 50 viewers and 15 editors warrants investigation -- most consumers should have view-only access.
Access Paths
A user can access a Power BI report through six distinct channels. A comprehensive distribution audit checks all of them.
Permission note: Endpoints prefixed with admin/ require the Fabric Admin role (tenant-level). Endpoints under groups/{wsId}/ require at minimum a workspace Admin role. See references/usage-metrics.md for the full permission matrix.
1. Workspace Role
The most common access path. Users assigned to the workspace inherit access to all items within it.
| Role | Can View | Can Edit | Can Manage |
|---|---|---|---|
| Viewer | Yes | No | No |
| Contributor | Yes | Yes | No |
| Member | Yes | Yes | Partial |
| Admin | Yes | Yes | Yes |
Check via API:
fab api -A powerbi "groups/{workspaceId}/users"Response fields: emailAddress, displayName, groupUserAccessRight, principalType (User, App, Group)
Note: Security groups appear as a single entry with principalType: Group. The API does not expand group membership. To enumerate individual users within a group, the Microsoft Graph API is required.
2. Direct Report Sharing
Reports can be shared directly with specific users, granting access without workspace membership.
Check via API (admin):
fab api -A powerbi "admin/reports/{reportId}/users"Response fields: emailAddress, reportUserAccessRight (Owner, ReadWrite, Read, ReadReshare), principalType
Non-admin alternative:
fab api -A powerbi "groups/{workspaceId}/reports/{reportId}/users"This returns users visible to the caller but may miss some if the caller lacks admin rights.
3. Workspace App (Power BI)
Reports can be packaged into an App and distributed to a broader audience. App users gain access to the report without workspace membership or direct sharing.
Check if workspace has an app:
fab api -A powerbi "admin/apps"
# Filter by workspaceId to find apps for the workspaceGet app users:
fab api -A powerbi "admin/apps/{appId}/users"Response fields: emailAddress, appUserAccessRight, principalType
Note: A workspace app can have multiple audiences with different content visibility. The API returns all users but does not indicate which audience they belong to.
4. Org App
Organizational apps are distributed through the admin portal and installed by users or auto-installed via admin settings.
Check via API:
fab api -A powerbi "admin/apps"
# Look for apps with the workspace's reportsOrg apps use the same API as regular apps. The distinction is in how the app is distributed (admin-installed vs user-published).
5. Publish-to-Web (Public Embed)
Publish-to-web creates a public, unauthenticated URL for the report. Anyone with the link can view the report. This is a significant security risk for reports containing internal data.
Check via API (Fabric Admin):
fab api -A powerbi "admin/widelySharedArtifacts/publishedToWeb"Response fields: artifactId, displayName, artifactType, shareType (PublishToWeb), sharer.emailAddress
Filter the response by artifactId matching the report's GUID.
Quick check for a specific report using fab CLI:
# Check if a specific report has active publish-to-web links
fab api -A powerbi "admin/widelySharedArtifacts/publishedToWeb" \
-q "ArtifactAccessEntities[?artifactId=='<report-id>']"If the result is non-empty, the report is publicly accessible. Flag this as a critical finding in any review unless the report is intentionally public-facing.
Revoking publish-to-web: Cannot be done via API. Direct the workspace admin or Fabric admin to revoke via the Power BI admin portal under "Embed codes".
6. Org-Wide Sharing Links
Reports can be shared with a link that is accessible to everyone in the organization.
Check via API (admin):
fab api -A powerbi "admin/widelySharedArtifacts/linksSharedToWholeOrganization"Response fields: Same as publish-to-web. Filter by artifactId.
Resolving Security Groups and Distribution Lists
The Power BI APIs return security groups and distribution lists as a single entry with principalType: Group. The API does not expand group membership. This means a workspace shared with Sales-Team@contoso.com appears as one entry, but may represent 50 users.
Expanding Group Membership
To enumerate individual users within a group, use the Microsoft Graph API:
# Get group members by group display name (requires Graph permissions)
# First, find the group's object ID
fab api -A azure "https://graph.microsoft.com/v1.0/groups?\$filter=displayName eq 'Sales-Team'"
# Then enumerate members
fab api -A azure "https://graph.microsoft.com/v1.0/groups/{groupId}/members"Required permissions: GroupMember.Read.All or Group.Read.All (delegated or application).
If Graph API access is unavailable, note the group names in the audit and flag that the actual audience size is unknown. Ask the user or an admin to provide group membership counts.
Nested Groups
Groups can contain other groups. The /members endpoint returns direct members only. To fully resolve nested groups:
# Transitive members (recursively expands nested groups)
fab api -A azure "https://graph.microsoft.com/v1.0/groups/{groupId}/transitiveMembers"This returns all users regardless of nesting depth.
Excluding Non-Consumer Users from Metrics
When evaluating whether a report is "being used," exclude users who are not the intended consumer audience. These users inflate viewer counts and distort adoption metrics.
Users to Exclude
| Category | How to Identify | Why Exclude |
|---|---|---|
| Service principals | principalType: App | Automation, not human viewers |
| Report developers | Workspace Admin or Member role who also appear in commit history or activity logs with UpdateReportContent events | Views during development are not consumption |
| Support / IT admins | Fabric Admin role, or users whose views correlate with UpdateReportContent, SetScheduledRefresh, or other admin activities | Maintenance access, not consumption |
| Testers / QA | Users who viewed only during a narrow window around the report's creation date, then stopped | Testing views, not ongoing consumption |
Practical Identification
Programmatic distinction between developers and consumers is imperfect. Use these heuristics:
1. Workspace role as a proxy: Users with Viewer role are almost always consumers. Users with Admin, Member, or Contributor roles may be developers -- cross-reference with their activity types. 2. Activity type cross-reference: Query the activity events API for the same user. If their activities include UpdateReportContent, CreateReport, or DeleteReport for this report, they are likely developers. If their only activity is ViewReport, they are consumers. 3. Ask the user: When the distinction matters, ask who the intended audience is. A report owner knows whether workspace admins are consumers or just maintainers. 4. View pattern analysis: Developers tend to have many views clustered around edit dates. Consumers tend to have views spread across regular intervals (daily, weekly).
When reporting audience reach, present two figures if developer/consumer distinction is ambiguous:
Audience reach (all users): 75% (6/8)
Audience reach (viewers only): 60% (3/5) [excl. 3 workspace admins]Audience Reach Calculation
This is the most reliable metric for evaluating report success. It should be evaluated in the last 7 days, 28 days, and 60 days (if possible). To calculate how effectively a report reaches its intended audience:
Reach % = (unique consumer viewers / total consumer users with access) * 100Where:
- Unique consumer viewers = distinct
UserIdvalues fromreportviewsendpoint, excluding service principals and identified developers/admins - Total consumer users with access = sum of unique human consumer principals across all access paths (deduplicated), with security groups expanded where possible
Interpreting Reach
| Reach | Interpretation |
|---|---|
| >80% | Strong adoption; report is well-targeted |
| 50-80% | Moderate; some users may not know about the report or find it useful |
| 20-50% | Low; investigate whether the audience is too broad or the report needs improvement |
| <20% | Very low; consider narrowing distribution or deprecating the report |
Caveats:
- Reach only captures views within the last 30 days (WABI metrics retention)
- Embedded views and API-driven consumption may not appear in view counts
- Security group membership is not expanded unless Graph API is available; actual human audience may be larger than what the Power BI APIs show
- Guest users (B2B) may have access but not appear in standard ACL queries
- Row-level security (RLS) may further restrict what users see even if they have report access
Script Reference
Use scripts/get_report_distribution.py to check all access paths:
# Full distribution audit
python3 scripts/get_report_distribution.py -w <workspace-id> -r <report-id>
# JSON output for programmatic use
python3 scripts/get_report_distribution.py -w <workspace-id> -r <report-id> --output jsonThe script checks all six access paths and produces a deduplicated summary showing each user's access paths and roles.
Export to Excel Analysis
Export to Excel is one of the most impactful user behaviors in Power BI. When users routinely export data to Excel, it often signals that the report is not meeting their analytical needs... they need to manipulate, combine, or further analyze the data outside of Power BI. It can also be a risk for governance and security.
Why It Matters
Export to Excel is a key review signal because it indicates:
1. The report is a data extraction tool, not an analytical tool. Users treat the report as a pipeline to get data into Excel rather than using it for insights. 2. The semantic model may lack needed measures or calculations. Users export raw data because the report doesn't provide the aggregations or comparisons they need. 3. The report design may be insufficient. Users may need to pivot, filter, or format data in ways the report doesn't support. 4. Data governance risk. Exported data leaves the governed Power BI environment. Sensitivity labels, RLS, and audit controls no longer apply. 5. Performance impact. Large exports consume capacity resources and can slow the service for other users.
Detecting Export to Excel
Activity Events API (Fabric Admin)
Export activities are captured in the activity events log. Query with:
fab api -A powerbi "admin/activityevents?startDateTime='YYYY-MM-DDT00:00:00'&endDateTime='YYYY-MM-DDT23:59:59'"Export-Related Activity Types
From the Fabric operation list:
| Activity Type | What It Tracks |
|---|---|
ExportReport | Visual data export (Excel, CSV) AND full report export (PDF, PPTX, PNG). This is the primary event for detecting Excel exports from visuals. |
ExportArtifact | Exported Power BI item to another file format |
ExportArtifactDownload | Downloaded an exported artifact file (.pptx or .pdf download completes) |
DownloadReport | Downloaded Power BI report as .pbix file |
ExportTile | Exported dashboard tile data |
For visual-level Excel exports (right-click a table/matrix -> "Export data"), the activity is ExportReport. This single event covers multiple export types:
- Data with current layout
- Summarized data (Excel)
- Summarized data (Excel live connected)
- Summarized data (CSV) -- note: CSV export may not be tracked in all cases; this has been reported as a bug
- Underlying data
The ExportEventPropertyList property in the event payload contains format details to distinguish between these export types. Inspect the raw JSON to determine the specific format.
Note: "Analyze in Excel" (AnalyzeInExcel, AnalyzedByExternalApplication) is a different consumption method, not an export. It creates a live connection to the model, not a data snapshot. Do not conflate it with visual data exports.
Timing: Activity events can take 30-60 minutes to appear in the log. Do not expect immediate results after an export.
Key Fields in Export Events
| Field | Description |
|---|---|
Activity | Activity type (ExportReport, AnalyzeInExcel, DownloadReport, etc.) |
UserId | Who exported |
ReportId / ReportName | Which report |
CreationTime | When the export occurred |
ExportEventPropertyList | Export format and configuration details (varies per event) |
ConsumptionMethod | How they accessed the report (Power BI Web, Mobile, etc.) |
Note: the activity log schema varies per event type. Not all fields are present on every event. Retrieve raw JSON and inspect the available fields for your specific scenario.
Analyzing Export Patterns
When reviewing export activity for a report, evaluate:
1. Frequency: How often are users exporting? Daily exports suggest a workflow dependency on Excel. 2. Users: Are many users exporting, or just one? A single heavy exporter may have a specific need; widespread export suggests a report design gap. 3. Which visuals: If export events include visual/page context, identify which visuals are being exported. These visuals likely need better in-report alternatives. 4. Timing: Do exports correlate with refresh schedules? Users may be exporting fresh data for downstream processes.
Recommendations by Pattern
| Pattern | Signal | Recommendation |
|---|---|---|
| Same users export daily | Report is a data pipeline | Consider a dataflow, lakehouse, or direct Excel connection to the semantic model instead |
| Many users export the same table/matrix | Table doesn't provide needed aggregation | Add measures, conditional formatting, or drill-through to eliminate the need |
| Users export then email the Excel | Report distribution is broken | Set up email subscriptions with PDF/Excel attachments |
| Users export to combine with other data | Semantic model is incomplete | Extend the model to include the additional data sources |
| Occasional exports for ad-hoc analysis | Normal behavior | No action needed; ensure RLS is applied |
Other Export Activities
Download as PBIX
The DownloadReport activity indicates a user downloaded the entire report as a .pbix file. This is a significant governance concern:
- The entire semantic model data may be included (Import mode)
- The user can open it locally without RLS enforcement
- Check tenant settings: "Download reports" can be disabled
Export to PDF / PPTX
The ExportReport activity with PDF or PPTX format is generally benign -- users are taking snapshots for presentations or documentation. High frequency may indicate the report should have an email subscription instead.
Export to PNG / Image
Visual-level image export is less common but can indicate users are embedding report visuals in other documents (Confluence, SharePoint pages, presentations). Consider using Power BI embedded or the publish-to-web feature (for non-sensitive content) instead.
Querying Export Activity
To find export events for a specific report over the last 7 days:
# Iterate over the last 7 days (activity API supports 1 day per request)
for i in $(seq 0 6); do
DATE=$(date -u -v-${i}d '+%Y-%m-%d')
fab api -A powerbi "admin/activityevents?startDateTime='${DATE}T00:00:00'&endDateTime='${DATE}T23:59:59'" \
-q "text.activityEventEntities[?Activity=='ExportReport' && ReportId=='<report-id>']"
doneFor DownloadReport events:
fab api -A powerbi "admin/activityevents?startDateTime='${DATE}T00:00:00'&endDateTime='${DATE}T23:59:59'" \
-q "text.activityEventEntities[?Activity=='DownloadReport' && ReportId=='<report-id>']"Performance Audit
Complements performance.md (which covers load-time telemetry and DAX query inference). This reference covers the query cost model, the Performance Analyzer export artifact, DirectQuery report-layer tuning, and the interaction/navigation audit.
Query Cost Model
Visual count is a proxy; query cost per visual is the real driver. Opening a page refreshes every visual. Each emits at least one DAX query; several emit more. Parallelism is capped (DirectQuery Maximum Connections per Data Source defaults to 10; service capacity imposes additional limits), so total page-load latency grows non-linearly once the parallel cap is hit.
Practical implication: 12 cheap card visuals can be fine; 8 matrices with totals and measure filters may not be.
Visuals that emit more than one query (multipliers):
- Tables/matrices with totals/subtotals (one query per band; DistinctCount/Median are worst)
- Measure filters (two queries)
- Top N filters (two queries; can blow the 1M-row intermediate limit under DirectQuery)
- Field parameters (an extra evaluated-parameters phase)
- Custom/Deneb/Python/R visuals (display phase dominates)
Review without Desktop:
pbir visuals query # read queryState per visual; flag measure-filters, TopN, totals, field paramsHidden and off-canvas visuals still query on load; include them in counts. Do not clear a flag based on visual count alone; a low-count page can be slow from one expensive visual.
Performance Analyzer Export
Performance Analyzer (Desktop-only export) produces a JSON log of every recorded operation. It is the one perf artifact parseable from the terminal without a live model. It breaks each visual's wall time into named phases:
DAX query: model/measure work (or DirectQuery source query); fix belongs with the modeler
Direct query: only present for DirectQuery tables; confirms live source round-trip
Visual display: render time; high here + low DAX = report-layer cost (fixable)
Other: queuing/serialization; large value here is a serialization symptom
Evaluated parameters: field-parameter overheadWorkflow: generate timings in Desktop, export the JSON, map each objectId/objectName to a visual.json name, apply fixes in PBIR, re-validate. Use the documented export format from microsoft/powerbi-desktop-samples rather than reverse-engineering fields.
When Desktop is unavailable, fall back to WABI reportloads telemetry (see performance.md) for absolute load times; use the Performance Analyzer export for relative attribution.
Notes:
- Durations are queue-inclusive; a high number does not prove a visual is intrinsically slow. Isolate with single-visual refresh before drawing conclusions
- The capturing machine differs from the service; use the export to find the relatively-worst visual, not to promise a specific millisecond figure
DirectQuery Report-Layer Tuning
Report-layer-only levers, all fixable inside .Report with pbir-cli. Separates "report agent can fix" from "kick to the modeler". Matters more under DirectQuery because every interaction is a live source round-trip (4-minute service timeout; 5s/30s usable/unusable guideline in practice).
Detect DirectQuery from the report side via the Performance Analyzer "Direct query" phase, or confirm storage mode via model skills before applying.
Apply before binding fields: write the filterConfig block before field bindings, or order pbir filters calls before pbir visuals bind. An unfiltered intermediate can hit the 1M-row limit.
Turn off unused totals/subtotals:
pbir visuals format <path> --property totals.show falseThese generate extra source queries; always extra cost for DistinctCount/Median.
Avoid measure filters and Top N on high-cardinality columns: they generate two source queries and can exceed the 1M-row limit. If Top N is required, scope it tight and prefer a model-side aggregation.
Prefer single-select slicers, or gate multi-select behind an Apply button: the highest-leverage fix; multi-select fires a query per item added.
Disable cross-highlight from slicers to expensive visuals:
# set a NoFilter pair from slicer to matrix visualUse visualInteractions[] with type NoFilter for slicer-to-expensive-visual pairs.
Keep visuals-per-page low: past the parallel-connection cap, visuals serialize and can show time-inconsistent results; this is a correctness argument, not just a speed one.
What not to change from `.Report`: Maximum Connections per Data Source is a model setting; recommend raising it but do not attempt to set it from .Report. Auto page refresh multiplies everything by frequency and concurrent users; flag it if present.
Do not blanket-apply DirectQuery tuning to Import reports; disabling cross-highlight removes interactivity for no gain.
Interaction and Navigation Audit
Defects that are invisible in a screenshot and do not fail pbir validate.
Cross-filter graph
Read each page's visualInteractions:
- Flag zero overrides on a page with both slicers and KPI cards; the cards likely jump on chart clicks (usually unintended)
- Flag a wall of
NoFiltereverywhere; interactivity may have been disabled rather than configured - Flag overrides referencing visual
names that no longer exist (stale, no-op; find by resolving names against actual visuals on the page)
Note: a NoFilter pair is sometimes correct (intentional KPI stability). Flag patterns and stale references, then ask the author about intent.
Drill propagation
Grep drillFilterOtherVisuals:
- Flag drillable visuals where page-wide drill response was clearly intended but left
false - Flag the inverse (drill response active on a page where it would confuse users)
Note: drillFilterOtherVisuals (hierarchy drill on same page) is distinct from drillthrough (page navigation).
Navigation integrity
Collect every visualLink with navigationSection/drillthroughSection/bookmark, resolve each target against actual page/bookmark names (not displayName):
- Flag dangling references
- Flag
WebUrlpointing at non-HTTPS or empty URLs - Flag navigator vs button sprawl: a row of near-identical
PageNavigationbuttons that a singlepageNavigatorwould replace
Drillthrough hygiene
- Confirm drillthrough pages are hidden in the view mode or carry a clear-filters reset
- Confirm a
Backbutton exists on drillthrough pages - A missing local target page is expected for cross-report drillthrough; do not flag it
Resolve all targets against name, not displayName. Stale references resolve empty at runtime without a validation error.
Report Performance Analysis
Reference for evaluating Power BI report performance through load time metrics, visual complexity analysis, and query inference from report metadata.
Load Time Metrics
Percentiles Explained
| Metric | Meaning | Target | Investigate |
|---|---|---|---|
| P10 | 90% of loads were slower than this (fastest 10%) | <1s | -- |
| P50 (median) | Half of loads were faster, half slower | <3s | >5s |
| P90 | Only 10% of loads were slower (slowest 10%) | <8s | >15s |
P50 is the typical user experience. P90 reveals the worst-case experience for the slowest 10% of users -- often caused by slow networks, mobile devices, complex filter states, or cold cache.
A large P50-to-P90 gap indicates inconsistent performance. Investigate:
- Geographic distribution (users far from the data region)
- Browser/device variation (mobile vs desktop)
- Filter-dependent query complexity (some slicer combinations produce expensive queries)
- Cache miss patterns (first view after refresh vs subsequent views)
Retrieving Load Times
Load time data comes from the WABI reportloads endpoint (Tier 1, workspace Viewer role):
python3 scripts/get_report_usage.py -w <workspace-id>
python3 scripts/get_report_detail.py -w <workspace-id> -r <report-id>The reportloads endpoint returns StartTime and EndTime per load event. Load time in seconds = EndTime - StartTime. Fields include LocationCity, LocationCountry, DeviceBrowserVersion, and Client for diagnosing environment-specific slowness.
For the pre-computed percentile DAX measures (P-10, P-50, P-90, P-25, 7-day variants), generate the Usage Metrics Model (Tier 2, workspace Contributor+). See references/usage-metrics.md.
Performance Audit Script
Use scripts/performance_audit.py to audit a single report's performance:
# Performance audit for a report
python3 scripts/performance_audit.py -w <workspace-id> -r <report-id>
# JSON output
python3 scripts/performance_audit.py -w <workspace-id> -r <report-id> --output jsonThe script collects load time metrics and analyzes the report definition for visual complexity indicators.
Visual Complexity Analysis
Performance problems in Power BI reports are almost always caused by what the visuals ask the semantic model to compute. Analyze the report definition to identify complexity hotspots.
Complexity Indicators
| Indicator | How to Check | Impact |
|---|---|---|
| Visual count per page | Count visual.json files per page directory | Each visual generates a separate DAX query |
| Field count per visual | Count projections in visual.query.queryState | More fields = wider query, more computation |
| Grouping column count | Count Column projections in grouping roles (Category, Rows, Series) | Grouping columns multiply cardinality exponentially |
| Extension measures | Check reportExtensions.json for measure definitions | Complex DAX in extension measures evaluates per data point |
| Conditional formatting | Check objects for rules, gradients, or measure-driven fill/color | Conditional formatting adds overhead to visual queries; measure-driven formatting adds extra query columns, but even rule-based and gradient formatting increases rendering cost |
| Tooltip pages | Check for pages with type: "Tooltip" | Tooltip pages execute additional queries on hover |
| Cross-filtering | Check drillFilterOtherVisuals in visual config | Cross-filtering chains cause cascading re-queries |
Reading Visual Field Bindings
Each visual's query is defined in visual.json under visual.query.queryState. The structure maps directly to the DAX query Power BI generates:
Field binding structure:
visual.query.queryState
.<RoleName> -- Category, Y, Values, Rows, Columns, Series, etc.
.projections[]
.field
.Column|Measure -- Dimension or measure reference
.Expression.SourceRef
.Entity -- Table name
.Schema -- "extension" if report-level measure
.Property -- Column/measure name
.queryRef -- "Table.Field" fully qualified referenceRole names vary by visual type:
| Visual Type | Grouping Roles | Measure Roles |
|---|---|---|
| lineChart, barChart, columnChart, areaChart | Category, Series | Y (Y2 for combo) |
| tableEx | Values (both dims and measures) | Values |
| pivotTable (matrix) | Rows, Columns | Values |
| card, cardVisual | -- | Values / Data |
| scatterChart | Category | X, Y, Size |
| slicer | Values | -- |
Inferring DAX Queries from Visual Metadata
Power BI translates each visual's field bindings into a SUMMARIZECOLUMNS query. Understanding this mapping reveals which visuals generate expensive queries.
Base pattern:
EVALUATE
SUMMARIZECOLUMNS(
'Table1'[GroupingColumn1], -- From Category/Rows role
'Table2'[GroupingColumn2], -- From Series/Columns role
"Measure1_Alias", 'Table'[Measure1], -- From Y/Values role
"Measure2_Alias", 'Table'[Measure2]
)To construct the query for a visual: 1. Collect all Column-type projections from grouping roles -- these become the first arguments to SUMMARIZECOLUMNS 2. Collect all Measure-type projections from measure roles -- these become "alias", Table[Measure] pairs 3. Check reportExtensions.json for extension measure definitions -- these may need a DEFINE block 4. Check objects for conditional formatting using measures -- these add hidden query columns
Example: A bar chart with Category: Date[Month], Y: Sales[Revenue], Sales[Margin %] generates:
EVALUATE
SUMMARIZECOLUMNS(
'Date'[Month],
"Revenue", 'Sales'[Revenue],
"Margin", 'Sales'[Margin %]
)Hidden Query Overhead
Not all DAX computation is visible in the visual's field wells. Additional query columns are generated by:
1. Conditional formatting -- All forms of conditional formatting add overhead. Measure-driven formatting (where objects.dataPoint.fill references a measure via expr.Measure) is the most expensive because it adds extra query columns evaluated per data point. Rule-based formatting and gradient fills are lighter but still increase rendering cost, especially on visuals with many data points.
2. Tooltip fields -- Custom tooltips may bind additional measures not shown in the main visual.
3. Sort-by-column -- If a column has a sortByColumn property in the model, the sort column is automatically added to the query even though it isn't displayed.
4. Data labels -- Dynamic data label formats or values may add measure evaluations.
Using DAX Queries for Performance Diagnosis
To identify which visual causes performance bottlenecks:
1. Extract field bindings from each visual's visual.json 2. Construct the equivalent SUMMARIZECOLUMNS query using the pattern above 3. Execute each query against the semantic model using the executeQueries API or DAX Studio 4. Measure execution time per query 5. Rank visuals by query cost
# Execute a DAX query against the model
fab api -A powerbi "groups/{wsId}/datasets/{datasetId}/executeQueries" \
-X post -i '{"queries":[{"query":"EVALUATE SUMMARIZECOLUMNS(...)"}]}'The most expensive queries reveal the visuals that need optimization. Common fixes:
- Reduce grouping columns or filter the data (page or report filters) (fewer dimensions = smaller result set)
- Simplify or remove conditional formatting where not essential
- Avoid or remove custom visuals that are overly-complex
- Audit the DAX and semantic model for issues there (see the semantic-models plugin and the
semantic-modelskill)
Report Metadata
Reference for retrieving and interpreting report-level metadata: thick vs thin, endorsement, sensitivity labels, deployment pipelines, and model properties.
Thick vs Thin Reports
A thin report connects to a published semantic model in the Power BI service. A thick report embeds its own semantic model (the .pbix model and report are bundled together).
Thin reports are preferred because:
- Multiple reports can share one model (single source of truth)
- Model changes propagate to all connected reports
- Separation of concerns (model team vs report team)
- Lower storage footprint
Detection
There is no direct API field for thick/thin. Infer it with these heuristics:
1. Same-name model in same workspace: If a workspace has Sales.Report and Sales.SemanticModel, the report is likely thick (auto-generated model from .pbix upload). 2. datasetWorkspaceId matches report workspace: Check the report's datasetWorkspaceId field. If it matches the report's workspace, it may be thick. If it points to a different workspace, it is thin. 3. Report-to-model ratio: A healthy workspace has more reports than models (many reports sharing few models). A 1:1 ratio suggests thick reports.
# Get report's connected model and workspace
fab api -A powerbi "groups/{wsId}/reports/{reportId}" \
-q "{datasetId: datasetId, datasetWorkspaceId: datasetWorkspaceId, name: name}"
# List all items to compare report:model ratio
fab ls "{workspace}.Workspace" -lDataHub V2 Fields for Models (undocumented)
The DataHub V2 API (/metadata/datahub/V2/artifacts with supportedTypes: ["Model"]) returns rich model metadata not available via standard APIs. The API is undocumented internal Microsoft surface area and may break without notice. The fields below are accessible via scripts/search_across_workspaces.py in the fabric-cli plugin (which surfaces them ahead of the identity fields shared with fab find).
| Field | Location | Description |
|---|---|---|
storageMode | artifact.storageMode | 1=Import, 2=DirectQuery; check directLakeMode for Direct Lake |
directLakeMode | artifact.directLakeMode | Boolean; true for Direct Lake models |
sizeInMBs | artifact.sizeInMBs | Model size on disk |
sharedFromEnterpriseCapacitySku | artifact.sharedFromEnterpriseCapacitySku | Capacity SKU (PP3, F64, etc.) |
refreshSchedule | artifact.refreshSchedule | Full refresh config (enabled, frequency, days) |
ownerUser | top-level | Full owner object (name, email, AAD ID) |
creatorUserPrincipalName | artifact.creatorUserPrincipalName | Who created the model |
lastRefreshTime | top-level (OData date) | Last data refresh |
lastVisitedTimeUTC | top-level | Last access timestamp |
isInEnterpriseCapacity | artifact.isInEnterpriseCapacity | Whether on paid capacity |
Endorsement Status
Reports and models can be endorsed as Certified (verified by a designated authority) or Promoted (recommended by the owner).
Checking Endorsement
# Via workspace scanner API (Fabric Admin)
# Step 1: Trigger scan
fab api "admin/workspaces/getInfo" -X post \
-i '{"workspaces":["<wsId>"]}'
# Step 2: Get scan result (after scan completes)
fab api "admin/workspaces/scanResult/<scanId>"
# Response includes endorsementDetails for each itemAlternatively, the admin/reports endpoint may return endorsementDetails if the report has been endorsed:
fab api -A powerbi "admin/reports/{reportId}"
# Check for endorsementDetails field (null if not endorsed)Endorsement values:
null-- Not endorsed{"endorsement": "Promoted"}-- Owner-promoted{"endorsement": "Certified", "certifiedBy": "user@org.com"}-- Certified by authority
Review Guidance
- Certified reports should have higher quality standards
- Promoted reports indicate the owner considers them ready for broader use
- Unendorsed reports in production workspaces may need review
Sensitivity Labels
Sensitivity labels (from Microsoft Purview) classify data confidentiality.
# Check via admin API (Fabric Admin)
fab api -A powerbi "admin/reports/{reportId}"
# Look for sensitivityLabel field
# Or via fab CLI
fab label get "{workspace}.Workspace/{report}.Report"Review Guidance
- Reports without labels in a tenant that requires them should be flagged
- Reports with high-sensitivity labels should not have publish-to-web enabled
- Check that sensitivity labels match the data classification of the underlying model
Deployment Pipeline Membership
Check whether a report is part of a CI/CD deployment pipeline:
# List all pipelines (Fabric Admin)
fab api -A powerbi "admin/pipelines"
# Get pipeline stages
fab api -A powerbi "admin/pipelines/{pipelineId}/stages"
# Match workspace IDs to find which pipeline contains the report's workspaceReview Guidance
- Reports in deployment pipelines follow a governed promotion process (dev -> test -> prod)
- Reports NOT in a pipeline that are in production workspaces may lack change management
- Check which stage the workspace is in (dev/test/prod) to understand the report's lifecycle position
Report Format
fab api -A powerbi "groups/{wsId}/reports/{reportId}" -q "format"| Format | Description |
|---|---|
PBIR | Power BI Report (new format, file-based, git-friendly) |
PBIT | Power BI Template |
| (empty/null) | Legacy PBIX format |
Quick Metadata Checklist
When reviewing a report, collect these metadata points:
Report: <name>
Format: PBIR / PBIX
Type: Thin / Thick
Model: <model name> (Import / DirectQuery / Direct Lake)
Model size: <n> MB
Endorsed: Certified / Promoted / None
Sensitivity: <label> / None
Pipeline: <pipeline name> stage <n> / None
Capacity: <SKU>
Owner: <email>Usage Interpretation
Guidance for reading modern Usage Metrics data accurately and converting view counts into a retire/keep/redesign decision. Avoids the failure mode of drawing wrong conclusions from structural telemetry limitations.
Modern Usage Metrics: What the Numbers Actually Mean
The modern Usage Metrics report has different metric definitions from the legacy report; treating them as equivalent produces wrong adoption verdicts.
Report View vs Report Page View
- Report View (server-side): one event per report open, reliable, matches audit logs
- Report Page View (client-side): one event per page render
Opening a report increments page views only for the landing page. "Page X has fewer page views than the report has views" is normal, not evidence the page is unused.
Structural Undercounting
Page-view undercounting is structural: ad blockers, firewalls, offline sessions, and embedded scenarios drop client telemetry silently. Low page-view counts are a floor, not a ceiling. Conclude "unused page" only when the whole report is unused, not from a page-level view count alone.
Before flagging an unused page, check:
pbir pages list+ navigators to confirm the page is reachable- Whether the page is a tooltip page or drillthrough target (these legitimately have no direct views)
The Blank Page Entry
Blank in the page slicer is not a real page; it represents pages added in the last 24h or since deleted. Do not investigate it.
Window and Retention
- 30-day window, 30-day retention, daily refresh with up to 24h lag
- "No views" means no views in 30 days; that is the wrong window for quarterly or annual reports
- Archive via Analyze in Excel or a scheduled extract for longer trends
Blind Spots
- App report pages and paginated reports are not in the Report pages table; absence of page views does not mean no engagement
- The platform slicer understates mobile/embedded usage precisely where client telemetry drops hardest; do not cite it as proof nobody uses mobile
Confidence Calibration
Tag conclusions by confidence:
- Report-level verdicts (views, viewers, rank): high confidence
- Page-level verdicts: lower confidence; always add an explicit telemetry-loss caveat
- "No views in 30 days": medium confidence; check window vs cadence before acting
Other notes:
Unnamed Usersis a tenant privacy setting, not missing data- A modern-vs-legacy "drop" in metrics is a definition change, not a regression
Retire / Keep / Redesign Verdict
Low usage is a question, not an answer. Use the following steps to turn view counts into a defensible action.
1. Match the window to the cadence
Read date-slicer grain, "as of" titles, and period filters to infer the report's cadence before applying a 30-day lens. An annual auditor model untouched for 11 months must be kept; a weekly operational report with no views in 30 days is a real signal.
2. Separate reach from adoption
Potential reach (people with access) vs actual reach (people who viewed it) are different numbers. A 500-person group with 8 viewers is an adoption problem, not necessarily a report problem. Investigate distribution and onboarding before touching the report.
3. Filter the viewer list
Strip before counting:
- Service principals (
principalType: App) - Report creators/owners (identified via
UpdateReportContent/CreateReportactivity events) - IT/support/admin users whose only activity is maintenance access
- One-or-two-time viewers in a narrow window near the report's creation date (likely developers or testers)
After filtering, work with the remaining consumer-only count.
4. Honor exception classes
Do not retire:
- Exec scorecards viewed by a small number of high-value users
- Compliance or audit content used intermittently but critical when needed
- Reports serving a seasonal cadence outside the 30-day window
5. Read trend, not level
A downward trend on a previously-used report is a stronger retire signal than flat-low with no prior history.
6. Emit a verdict
One of four outcomes:
- Keep: active reach, trend stable or rising, exception class, or cadence outside window
- Investigate-distribution: low actual reach relative to potential reach; distribution or onboarding issue
- Redesign: used but poorly (low per-viewer frequency, declining trend, design issues explaining the drop)
- Retire: low actual reach + downward trend + no exception class + confirmed by owner/SME
Always require owner/SME confirmation as the final gate before retiring.
7. Soft-retire for code-managed reports
Rename with a deprecation prefix rather than delete; leave the definition in source control. The admin REST Get Unused Artifacts as Admin only looks back 30 days, so trend decisions need archived activity history.
Notes:
- Never recommend deletion off a single 30-day "no views"
- Permission breadth is not consumption; a widely-shared report can still be unused
- A low-but-loyal personal/team-BI report with a small dedicated audience is a valid scenario, not a failure
Power BI Report Usage APIs
Complete reference for retrieving report usage data programmatically. Includes both official (documented) and internal (undocumented) APIs.
API Tiers Overview
| Tier | Source | Minimum Permission | Scope | View Counts | Page Views | Load Times |
|---|---|---|---|---|---|---|
| 1 | WABI Metrics | Workspace Viewer | Workspace | Yes | Yes | Yes |
| 2 | Usage Metrics Model | Workspace Contributor | Workspace | Yes | Yes | Yes |
| 3 | DataHub V2 | Any authenticated user | Cross-workspace | No | No | No |
| 4 | Activity Events | Fabric Admin (tenant) | Tenant-wide | Yes | No | No |
Tier 1 is generally the recommended default -- it provides page views and load times directly via WABI endpoints (reportpagesectionviews, reportloads) without needing to generate a model. Tier 2 is only needed for the pre-built DAX measures (trend calculations, percentile measures, rank strings). Note that 1-3 are all going via undocumented APIs.
Permission Requirements by API
| API / Endpoint | Required Role | Notes |
|---|---|---|
WABI /reportviews | Workspace Viewer+ | Any workspace role |
WABI /reportpagesectionviews | Workspace Viewer+ | Any workspace role |
WABI /reportloads | Workspace Viewer+ | Any workspace role |
WABI /reportmetadata | Workspace Viewer+ | Any workspace role |
WABI /reportpagesectionmetadata | Workspace Viewer+ | Any workspace role |
WABI /reportrank | Workspace Viewer+ | Any workspace role |
| Usage Metrics Model generation | Workspace Contributor+ | Contributor, Member, or Admin |
executeQueries (DAX on model) | Workspace Contributor+ | Same as model generation |
DataHub V2 /artifacts | Any authenticated user | Cross-workspace; no role needed |
admin/activityevents | Fabric Admin (tenant-level) | Tenant admin role in Fabric/Power BI |
admin/reports/{id}/subscriptions | Fabric Admin (tenant-level) | Tenant admin role |
admin/reports/{id}/users | Fabric Admin (tenant-level) | Tenant admin; broader than workspace groups/{id}/reports/{id}/users |
admin/users/{id}/subscriptions | Fabric Admin (tenant-level) | Tenant admin role |
admin/widelySharedArtifacts/* | Fabric Admin (tenant-level) | Publish-to-web and org-wide sharing checks |
admin/apps | Fabric Admin (tenant-level) | List all org apps |
groups/{wsId}/users | Workspace Admin | Workspace-level admin role |
groups/{wsId}/reports/{id}/users | Workspace Admin | Workspace-level; returns fewer results than admin API |
Key distinction:
- Workspace roles (Viewer, Contributor, Member, Admin) are per-workspace and control access to items within that workspace
- Fabric Admin (also called Power BI Admin or Tenant Admin) is a tenant-wide role granting access to all
admin/*API endpoints across all workspaces
Tier 1: WABI Metrics Endpoints (Undocumented)
Internal Power BI endpoints that return usage data without generating a model. These are the same endpoints used by the Power Query M expressions inside the Usage Metrics Report's semantic model.
Base URL: https://{cluster-host}/metadata/v201906/metrics/workspace/{workspaceId}/
The cluster host is region-specific. Common values:
| Region | Host |
|---|---|
| West Europe | wabi-west-europe-e-primary-redirect.analysis.windows.net |
| US East | wabi-us-east-a-primary-redirect.analysis.windows.net |
| UK South | wabi-uk-south-a-primary-redirect.analysis.windows.net |
Discover the cluster host from any Power BI API response's home-cluster-uri header.
reportviews
Individual report view events for the workspace (last 30 days).
GET /metadata/v201906/metrics/workspace/{wsId}/reportviewsResponse fields:
| Field | Type | Description |
|---|---|---|
| ReportId | string | Report GUID |
| ReportType | string | PowerBIReport or PaginatedReport |
| ReportName | string | Display name |
| CreationTime | datetime | When the view occurred |
| AppName | string | App name if viewed via app, else null |
| UserKey | string | Hashed user identifier |
| UserId | string | User email (if per-user data enabled) |
| UserAgent | string | Browser user agent string |
| DatasetName | string | Connected semantic model name |
| DistributionMethod | string | Workspace, App, ShareLink, etc. |
| CapacityId | string | Capacity GUID |
| CapacityName | string | Capacity display name |
| ConsumptionMethod | string | Power BI Web, Power BI Mobile, etc. |
reportmetadata
Report names and IDs for the workspace.
GET /metadata/v201906/metrics/workspace/{wsId}/reportmetadataResponse fields: ReportId, ReportName, WorkspaceId, OrganizationId, IsUsageMetricsReport
reportpagesectionmetadata
Page names and section IDs per report (current pages only).
GET /metadata/v201906/metrics/workspace/{wsId}/reportpagesectionmetadataResponse fields: ReportId, SectionId, SectionName, WorkspaceId
reportrank
Report view counts and organization-wide ranking.
GET /metadata/v201906/metrics/workspace/{wsId}/reportrankResponse fields:
| Field | Type | Description |
|---|---|---|
| ReportId | string | Report GUID |
| WorkspaceId | string | Workspace GUID |
| ReportViewCount | int | Total views in ranking period |
| ReportRank | int | Rank among all org reports (1 = most viewed) |
| TotalReportCount | int | Total reports in org |
| TenantId | string | Tenant GUID |
reportpagesectionviews
Individual page-level view events with section IDs and timestamps. Joins to reportpagesectionmetadata for page names.
GET /metadata/v201906/metrics/workspace/{wsId}/reportpagesectionviewsResponse fields:
| Field | Type | Description |
|---|---|---|
| Timestamp | datetime | When the page was viewed |
| ReportId | string | Report GUID |
| SectionId | string | Page section GUID (join to reportpagesectionmetadata) |
| UserId | string | User AAD GUID |
| UserKey | string | Hashed user identifier |
| Client | string | Power BI Web, Power BI Mobile, etc. |
| DeviceOSVersion | string | OS version (e.g. Mac OS X 10.15) |
| DeviceBrowserVersion | string | Browser version (e.g. Chrome 145.0) |
| GroupId | string | Workspace GUID |
| AppName | string | App name if viewed via app, else null |
| PbiCluster | string | WABI cluster name |
reportloads
Report load time events with start/end timestamps and geographic location.
GET /metadata/v201906/metrics/workspace/{wsId}/reportloadsResponse fields:
| Field | Type | Description |
|---|---|---|
| Timestamp | datetime | Event timestamp |
| ReportId | string | Report GUID |
| UserId | string | User AAD GUID |
| StartTime | datetime | When report load started |
| EndTime | datetime | When report load completed |
| LocationCity | string | City of the viewer (e.g. Brussels) |
| LocationCountry | string | Country of the viewer (e.g. Belgium) |
| Client | string | Power BI Web, Power BI Mobile, etc. |
| DeviceOSVersion | string | OS version |
| DeviceBrowserVersion | string | Browser version |
| GroupId | string | Workspace GUID |
| PbiCluster | string | WABI cluster name |
| AppName | string | App name if via app, else null |
Note: Load time in seconds = (EndTime - StartTime).total_seconds(). This is the raw measurement; the Usage Metrics Model computes loadTime as a calculated column from these fields.
dashboardviews
Individual dashboard view events (same structure as reportviews).
GET /metadata/v201906/metrics/workspace/{wsId}/dashboardviewsTier 2: Usage Metrics Model (Undocumented Generation, Official Querying)
The Usage Metrics Model is a hidden semantic model generated per workspace. It contains detailed page-level views, load times, and performance data collected from client telemetry.
Generating the Model
GET https://{cluster-host}/beta/myorg/groups/{wsId}/usageMetricsReportV2Returns 200 or 202. The response includes the model metadata with models[0].dbName containing the dataset GUID. This is the same action as clicking "View usage metrics" in the Power BI service UI.
Requirements: Workspace Contributor, Member, or Admin role.
Querying the Model
Use the standard Power BI executeQueries API:
POST https://api.powerbi.com/v1.0/myorg/groups/{wsId}/datasets/{datasetId}/executeQueries
{
"queries": [{"query": "EVALUATE 'Report page views'"}],
"serializerSettings": {"includeNulls": true}
}Available Tables
Report views -- Individual report open events (server-side telemetry).
Columns: ReportId, ReportType, ReportName, AppName, UserKey, UserId, UserAgent, DatasetName, CapacityId, CapacityName, Date, CreationTime, DistributionMethod, OriginalConsumptionMethod, ConsumptionMethod
Report page views -- Page-level view events (client-side telemetry).
Columns: AppName, UserId, ReportId, Date, Timestamp, AppGuid, Client, DeviceBrowserVersion, DeviceOSVersion, WorkspaceId, OriginalWorkspaceId, OriginalReportId, SectionId, TenantId, SessionSource, UserKey
Note: SectionId joins to Report pages.SectionId for page names.
Report pages -- Current page names per report.
Columns: ReportId, SectionId, SectionName, WorkspaceId
Report load times -- Performance data per report load (client-side telemetry).
Columns: Timestamp, PbiCluster, AppName, TenantId, UserId, ReportId, GroupId, Client, StartTime, EndTime, DeviceOSVersion, LocationCity, Country, loadTime, Date, OriginalReportId, OriginalGroupId, DeviceBrowserVersion, Browser, AppGuid, SessionSource
Reports -- Report catalog for the workspace.
Columns: OrganizationId, ReportGuid, ReportName, WorkspaceId, IsUsageMetricsReport
Users -- User lookup (derived from Report views).
Columns: UserId, UserKey, UserGuid, UniqueUser
Report rank -- Organization-wide report ranking.
Columns: ReportId, WorkspaceId, ReportViewCount, ReportRank, TotalReportCount, TenantId
Workspace views -- Aggregated views per report/user/method.
Columns: ReportId, UserId, DistributionMethod, ConsumptionMethod, Views, UserKey, UniqueUser
Model measures -- Pre-built DAX measures.
| Measure | Formula | Description |
|---|---|---|
| Report views | COUNTROWS('Report views') | Total report opens |
| Report viewers | DISTINCTCOUNT('Report views'[UserKey]) | Unique viewers |
| Page view share | DIVIDE(COUNTROWS('Report page views'), CALCULATE(COUNTROWS('Report page views'), ALL(...))) | Page's share of total |
| View trend | Compares first vs second half of period | Engagement trend |
| P-50 | PERCENTILE.INC('Report load times'[loadTime], 0.5) | Median load time |
| P-10 / P-90 | PERCENTILE at 0.1 / 0.9 | Load time range |
Limitations
- Page views use client telemetry; can be undercounted due to ad blockers or network issues
- Load times use client telemetry; similar undercounting risk
- Model data covers last 30 days
- Private link environments may not capture client telemetry
Tier 3: DataHub V2 API (Undocumented)
Cross-workspace metadata including lastVisitedTimeUTC -- when any user last accessed an item. The existing search_across_workspaces.py script in the fabric-cli skill provides full access to this API.
POST https://{cluster-host}/metadata/datahub/V2/artifacts
{
"filters": [{"datahubFilterType": "workspace", "values": ["<wsId>"]}],
"supportedTypes": ["PowerBIReport"],
"tridentSupportedTypes": ["powerbireport"],
"pageSize": 200,
"pageNumber": 1
}Unique fields not available elsewhere:
| Field | Description |
|---|---|
lastVisitedTimeUTC | When item was last opened by any user |
lastRefreshTime | When model data was last refreshed |
isDiscoverable | Whether item appears in search |
permissions | Numeric permission level |
Tier 4: Activity Events API (Official, Admin Required)
The official admin API for audit logging. Returns server-side activity events for the entire tenant.
GET https://api.powerbi.com/v1.0/myorg/admin/activityevents?startDateTime='YYYY-MM-DDT00:00:00'&endDateTime='YYYY-MM-DDT23:59:59'Key activity types for reports: ViewReport, ShareReport, UpdateReportContent, CreateReport, DeleteReport, ExportReport
Limitations:
- Only report-level events (no page views)
- Admin role required
- Maximum 1 day per request
- Up to 28 days of history
- Continuation token pagination required for large result sets
- Rate limited to 200 requests per hour
ViewReport event fields: ReportId, ReportName, ReportType, DatasetId, DatasetName, WorkspaceId, WorkSpaceName, UserId, CreationTime, DistributionMethod, ConsumptionMethod, CapacityId, CapacityName, UserAgent, ClientIP
Email Subscriptions
Email subscriptions deliver report snapshots to users on a schedule. Subscription recipients receive the report without actively viewing it in the Power BI service. This means subscription-delivered views do not count as report views in the usage metrics data. A report may have active subscribers who never appear in the view counts.
When evaluating adoption, check subscriptions separately to get a complete picture of report consumption.
Get Report Subscriptions (Admin)
fab api -A powerbi "admin/reports/{reportId}/subscriptions"Response fields:
| Field | Type | Description |
|---|---|---|
| id | string | Subscription GUID |
| title | string | Subscription name |
| artifactId | string | Report GUID |
| artifactDisplayName | string | Report name |
| subArtifactDisplayName | string | Subscribed page name (if page-specific) |
| isEnabled | bool | Whether subscription is active |
| frequency | string | Delivery cadence (Daily, Weekly, etc.) |
| startDate | datetime | Subscription start |
| endDate | datetime | Subscription end |
| users | array | Recipients (email addresses) |
Get User Subscriptions (Admin)
fab api -A powerbi "admin/users/{userId}/subscriptions"Returns all subscriptions for a specific user across all workspaces.
Subscription Activity Events
Subscription-related activities in the activity log:
| Activity | Description |
|---|---|
CreateEmailSubscription | New subscription created |
UpdateEmailSubscription | Subscription modified |
DeleteEmailSubscription | Subscription deleted |
Interpreting Subscriptions in a Review
- A report with 0 views but active subscriptions is being consumed passively -- it is not unused
- Count subscription recipients as part of the effective audience, but note they are passive consumers
- If subscription recipients never also view the report interactively, the report may benefit from being converted to a paginated report or automated email (lower overhead)
Analyzing View Trends
Rolling 7-Day Average
Raw daily view counts are noisy (weekends, holidays, one-off spikes). Use a rolling 7-day average to identify the underlying trend. When presenting usage data:
1. Calculate the 7-day rolling average of daily views 2. Compare the current 7-day average to the previous period 3. Flag reports where the 7-day average is declining consistently
Interpretation:
| 7D Avg Trend | Signal |
|---|---|
| Stable or rising | Report has consistent, healthy adoption |
| Declining over 2+ weeks | Adoption is dropping; investigate cause |
| Spike then decline | One-time interest (launch, presentation); not sustained |
| Flat near zero | Report is unused or only viewed occasionally |
Implementation: The reportviews endpoint (Tier 1) returns individual view events with CreationTime. Group by day, compute the rolling average over 7-day windows, and compare the most recent window to the prior window.
Adjusting for Subscription Recipients
When reporting total consumption, combine:
Total active consumers = unique interactive viewers + unique subscription recipientsBut note that interactive viewers and subscription recipients may overlap. Deduplicate by email address when possible.
Filtering Viewers for Accurate Adoption Metrics
Raw usage data includes views from developers, admins, and service principals alongside real consumers. For an accurate picture of report adoption, filter the viewer data.
Exclude from Consumer Metrics
| Principal Type | Identification | Rationale |
|---|---|---|
| Service principals | principalType: App in ACL data; no email address | Automation, not human consumption |
| Report developers | Workspace Admin/Member/Contributor who also have UpdateReportContent or CreateReport activity events for this report | Development views, not consumption |
| IT/support admins | Users with Fabric Admin role; users whose only activity is admin operations | Maintenance access |
Identification via Activity Events
Cross-reference viewer UserId values against the Activity Events API (Tier 4). A user whose activities for a report include UpdateReportContent, CreateReport, or DeleteReport is likely a developer, not a consumer. A user whose only activity is ViewReport is likely a consumer.
# Get activity events and filter for a specific user + report
fab api -A powerbi "admin/activityevents?startDateTime='...'&endDateTime='...'"
# Filter results by UserId and ReportId, check Activity fieldView Pattern Heuristics
When activity event access is unavailable, use view patterns as a proxy:
- Developer pattern: Burst of views clustered around report edit dates, then drops off
- Consumer pattern: Regular views spread across days/weeks (daily check-in, weekly review)
- One-time tester: Views only in a narrow window near the report's creation date
Security Groups and Distribution Lists
Power BI APIs do not expand group membership. A group like Sales-Team@contoso.com appears as one ACL entry but may represent dozens of users. See references/distribution.md for how to expand groups via the Microsoft Graph API (/groups/{id}/transitiveMembers).
When group expansion is not possible, note the group names and flag that the actual audience size is unknown. The reach percentage will be inaccurate if significant access is granted via groups.
Authentication
All APIs use Azure AD bearer tokens with the Power BI API resource scope:
Resource: https://analysis.windows.net/powerbi/apiObtain via Azure CLI:
az account get-access-token --resource https://analysis.windows.net/powerbi/apiOr via fab CLI (handles auth internally for standard API calls via fab api -A powerbi).
Exported Dataset Schema
The usage-metrics-dataset/ directory contains a full export of the Usage Metrics Report (both the .Report and .SemanticModel definitions). The TMDL files in Usage Metrics Report.SemanticModel/definition/tables/ document the complete schema including:
- Column definitions and data types
- Power Query M source expressions revealing WABI endpoint patterns
- Pre-built DAX measures for common analytics
- Relationships between tables
#!/usr/bin/env python3
"""
Report Detail Script
get_report_detail.py
Deep-dive into a single Power BI report's usage: daily view breakdown,
per-viewer stats, page-level analytics, load times, and audience reach.
AGENT USAGE GUIDE:
------------------
Use this script to evaluate a single report's adoption and engagement.
It answers: who is looking at it, how frequently, which pages, and how
does actual viewership compare to the total possible audience.
COMMON PATTERNS:
# Full report detail
python3 get_report_detail.py -w <workspace-id> -r <report-id>
# JSON output for programmatic use
python3 get_report_detail.py -w <workspace-id> -r <report-id> --output json
# Specify region
python3 get_report_detail.py -w <workspace-id> -r <report-id> --region us-east
PREREQUISITES:
- Azure CLI authenticated: `az login`
- Python packages: requests (uv pip install requests)
TOKEN SECURITY:
Auth tokens obtained via `az account get-access-token` in a subprocess.
Tokens held in memory only -- never printed, logged, or written to disk.
OUTPUT:
- Daily view counts over the reporting period
- Per-viewer breakdown (views, last seen, consumption method)
- Page-level view counts with page names
- Load time percentiles and geographic distribution
- Audience reach: viewers vs users with access (via ACL)
"""
import argparse
import json
import subprocess
import sys
from collections import defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional
try:
import requests
except ImportError:
print("Error: 'requests' package required. Install with: uv pip install requests", file=sys.stderr)
sys.exit(1)
#region Variables
REGIONS = {
"west-europe": "wabi-west-europe-e-primary-redirect.analysis.windows.net",
"north-europe": "wabi-north-europe-j-primary-redirect.analysis.windows.net",
"us-east": "wabi-us-east-a-primary-redirect.analysis.windows.net",
"us-east2": "wabi-us-east2-b-primary-redirect.analysis.windows.net",
"us-west": "wabi-us-west-d-primary-redirect.analysis.windows.net",
"us-north-central": "wabi-us-north-central-c-primary-redirect.analysis.windows.net",
"us-south-central": "wabi-us-south-central-e-primary-redirect.analysis.windows.net",
"south-east-asia": "wabi-south-east-asia-b-primary-redirect.analysis.windows.net",
"australia-east": "wabi-australia-east-b-primary-redirect.analysis.windows.net",
"brazil-south": "wabi-brazil-south-a-primary-redirect.analysis.windows.net",
"canada-central": "wabi-canada-central-a-primary-redirect.analysis.windows.net",
"india-west": "wabi-india-west-a-primary-redirect.analysis.windows.net",
"japan-east": "wabi-japan-east-a-primary-redirect.analysis.windows.net",
"uk-south": "wabi-uk-south-a-primary-redirect.analysis.windows.net",
}
DEFAULT_REGION = "west-europe"
#endregion
#region Authentication
def get_token() -> Optional[str]:
"""
Obtain a Power BI API access token via Azure CLI.
Returns the access token string, or None on failure.
Tokens are captured in-process and never printed or logged.
"""
try:
result = subprocess.run(
["az", "account", "get-access-token",
"--resource", "https://analysis.windows.net/powerbi/api"],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
data = json.loads(result.stdout)
return data.get("accessToken")
print("Error: Azure CLI not authenticated. Run 'az login' first.", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print("Error: Token request timed out.", file=sys.stderr)
return None
except FileNotFoundError:
print("Error: Azure CLI not installed. Install with 'brew install azure-cli'.", file=sys.stderr)
return None
except Exception as e:
print(f"Error getting token: {type(e).__name__}", file=sys.stderr)
return None
#endregion
#region WABI API
def wabi_get(token: str, region: str, workspace_id: str, endpoint: str) -> Optional[List[Dict]]:
"""
Call a WABI metrics endpoint for a workspace.
Args:
token: Power BI access token
region: Region key from REGIONS dict
workspace_id: Workspace GUID
endpoint: Metric type
Returns:
List of dicts on success, None on error.
"""
host = REGIONS.get(region, REGIONS[DEFAULT_REGION])
url = f"https://{host}/metadata/v201906/metrics/workspace/{workspace_id}/{endpoint}"
headers = {"Authorization": f"Bearer {token}"}
try:
resp = requests.get(url, headers=headers, timeout=30)
if resp.status_code == 200:
return resp.json()
else:
print(f"Warning: WABI {endpoint} returned {resp.status_code}", file=sys.stderr)
return None
except Exception as e:
print(f"Warning: WABI {endpoint} failed: {type(e).__name__}", file=sys.stderr)
return None
#endregion
#region ACL Lookup
def get_report_acl(workspace_id: str, report_id: str) -> List[Dict[str, str]]:
"""
Retrieve the access control list for a report via fab CLI.
Uses workspace-level and report-level APIs to get all users.
Service principals (type=App) are included but flagged.
Args:
workspace_id: Workspace GUID
report_id: Report GUID
Returns:
List of dicts with 'principal', 'role', and 'type' keys.
"""
acl = []
# Workspace-level permissions
try:
result = subprocess.run(
["fab", "api", "-A", "powerbi",
f"groups/{workspace_id}/users"],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
raw = json.loads(result.stdout)
data = raw.get("text", raw)
users = data.get("value", [])
for u in users:
acl.append({
"principal": u.get("emailAddress") or u.get("displayName") or u.get("identifier", "?"),
"role": u.get("groupUserAccessRight", "?"),
"type": u.get("principalType", "?"),
})
except Exception:
pass
# Report-level permissions
try:
result = subprocess.run(
["fab", "api", "-A", "powerbi",
f"groups/{workspace_id}/reports/{report_id}/users"],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
raw = json.loads(result.stdout)
data = raw.get("text", raw)
users = data.get("value", [])
existing = {a["principal"] for a in acl}
for u in users:
principal = u.get("emailAddress") or u.get("displayName") or u.get("identifier", "?")
if principal not in existing:
acl.append({
"principal": principal,
"role": u.get("reportUserAccessRight", "?"),
"type": u.get("principalType", "?"),
})
except Exception:
pass
return acl
#endregion
#region Data Collection
def collect_report_data(
token: str, region: str, workspace_id: str, report_id: str
) -> Dict[str, Any]:
"""
Collect all usage data for a single report from WABI endpoints.
Args:
token: Power BI access token
region: Region key
workspace_id: Workspace GUID
report_id: Report GUID
Returns:
Dict with report_views, page_views, report_loads, page_metadata,
report_rank, and report_metadata filtered to the target report.
"""
report_views = wabi_get(token, region, workspace_id, "reportviews") or []
page_views = wabi_get(token, region, workspace_id, "reportpagesectionviews") or []
report_loads = wabi_get(token, region, workspace_id, "reportloads") or []
page_metadata = wabi_get(token, region, workspace_id, "reportpagesectionmetadata") or []
report_rank = wabi_get(token, region, workspace_id, "reportrank") or []
report_metadata = wabi_get(token, region, workspace_id, "reportmetadata") or []
return {
"report_views": [v for v in report_views if v.get("ReportId") == report_id],
"page_views": [v for v in page_views if v.get("ReportId") == report_id],
"report_loads": [v for v in report_loads if v.get("ReportId") == report_id],
"page_metadata": [p for p in page_metadata if p.get("ReportId") == report_id],
"report_rank": [r for r in report_rank if r.get("ReportId") == report_id],
"report_metadata": [r for r in report_metadata if r.get("ReportId") == report_id],
}
#endregion
#region Analysis
def analyze_report(data: Dict[str, Any], acl: List[Dict]) -> Dict[str, Any]:
"""
Analyze collected report data into a structured detail summary.
Args:
data: Output from collect_report_data
acl: Output from get_report_acl
Returns:
Dict with overview, daily_views, viewers, pages, performance, audience.
"""
views = data["report_views"]
page_views = data["page_views"]
loads = data["report_loads"]
pages_meta = data["page_metadata"]
rank = data["report_rank"]
meta = data["report_metadata"]
# Page name lookup
page_names = {}
for p in pages_meta:
page_names[p.get("SectionId", "")] = p.get("SectionName", "?")
# Report name
report_name = "?"
if meta:
report_name = meta[0].get("ReportName", "?")
elif views:
report_name = views[0].get("ReportName", "?")
# Overview
overview = {
"name": report_name,
"total_views": len(views),
"unique_viewers": len({v.get("UserId", "") for v in views if v.get("UserId")}),
"rank": rank[0].get("ReportRank") if rank else None,
"rank_total": rank[0].get("TotalReportCount") if rank else None,
"rank_view_count": rank[0].get("ReportViewCount") if rank else None,
}
# Daily views
daily = defaultdict(int)
for v in views:
ts = v.get("CreationTime", "")
if ts:
day = ts[:10]
daily[day] += 1
daily_sorted = sorted(daily.items())
# Per-viewer breakdown
viewer_stats = defaultdict(lambda: {
"views": 0, "last_seen": "", "methods": set(), "agents": set()
})
for v in views:
uid = v.get("UserId", "")
if not uid:
continue
viewer_stats[uid]["views"] += 1
ts = v.get("CreationTime", "")
if ts > viewer_stats[uid]["last_seen"]:
viewer_stats[uid]["last_seen"] = ts
method = v.get("ConsumptionMethod", "")
if method:
viewer_stats[uid]["methods"].add(method)
agent = v.get("UserAgent", "")
if agent:
# Extract browser name
if "Chrome" in agent:
viewer_stats[uid]["agents"].add("Chrome")
elif "Firefox" in agent:
viewer_stats[uid]["agents"].add("Firefox")
elif "Safari" in agent and "Chrome" not in agent:
viewer_stats[uid]["agents"].add("Safari")
elif "Edge" in agent:
viewer_stats[uid]["agents"].add("Edge")
# Convert sets to lists
viewers = {}
for uid, stats in sorted(viewer_stats.items(), key=lambda x: -x[1]["views"]):
viewers[uid] = {
"views": stats["views"],
"last_seen": stats["last_seen"],
"methods": list(stats["methods"]),
"browsers": list(stats["agents"]),
}
# Page views by page by day
page_daily = defaultdict(lambda: defaultdict(int))
page_totals = defaultdict(int)
for pv in page_views:
sid = pv.get("SectionId", "")
ts = pv.get("Timestamp", "")
if sid and ts:
day = ts[:10]
pname = page_names.get(sid, sid[:12] + "...")
page_daily[pname][day] += 1
page_totals[pname] += 1
pages_result = {}
for pname, total in sorted(page_totals.items(), key=lambda x: -x[1]):
pages_result[pname] = {
"total_views": total,
"daily": dict(sorted(page_daily[pname].items())),
}
# Performance
load_times = []
locations = defaultdict(int)
browsers = defaultdict(int)
for rl in loads:
start = rl.get("StartTime")
end = rl.get("EndTime")
if start and end:
try:
t_start = datetime.fromisoformat(start)
t_end = datetime.fromisoformat(end)
secs = (t_end - t_start).total_seconds()
if secs >= 0:
load_times.append(secs)
except (ValueError, TypeError):
pass
city = rl.get("LocationCity", "")
country = rl.get("LocationCountry", "")
if city and country:
locations[f"{city}, {country}"] += 1
browser = rl.get("DeviceBrowserVersion", "")
if browser:
browsers[browser] += 1
performance = {}
if load_times:
load_times.sort()
n = len(load_times)
performance = {
"sample_count": n,
"p10": load_times[max(0, int(n * 0.1))],
"p50": load_times[max(0, int(n * 0.5))],
"p90": load_times[max(0, min(n - 1, int(n * 0.9)))],
"min": load_times[0],
"max": load_times[-1],
"locations": dict(sorted(locations.items(), key=lambda x: -x[1])),
"browsers": dict(sorted(browsers.items(), key=lambda x: -x[1])),
}
# Audience reach (exclude service principals from human audience metrics)
human_acl = [a for a in acl if a.get("type") != "App"]
sp_acl = [a for a in acl if a.get("type") == "App"]
human_count = len(human_acl)
audience = {
"total_with_access": human_count,
"service_principals": len(sp_acl),
"actual_viewers": overview["unique_viewers"],
"reach_pct": round(overview["unique_viewers"] / human_count * 100, 1) if human_count else None,
"access_list": human_acl,
"non_viewers": [],
}
# Identify human users with access who haven't viewed
viewer_emails = {v.get("UserId", "").lower() for v in views if v.get("UserId")}
for a in human_acl:
principal = a.get("principal", "").lower()
if principal and principal not in viewer_emails:
audience["non_viewers"].append(a["principal"])
return {
"overview": overview,
"daily_views": daily_sorted,
"viewers": viewers,
"pages": pages_result,
"performance": performance,
"audience": audience,
}
#endregion
#region Output Formatting
def format_detail(analysis: Dict[str, Any]) -> str:
"""
Format the analysis as a readable ASCII report.
Args:
analysis: Output from analyze_report
Returns:
Formatted string with detailed report usage.
"""
o = analysis["overview"]
lines = []
lines.append("=" * 72)
lines.append(f" REPORT DETAIL: {o['name']}")
lines.append("=" * 72)
# Overview
rank_str = f"#{o['rank']}/{o['rank_total']}" if o.get("rank") else "N/A"
lines.append("")
lines.append(f" Total views: {o['total_views']}")
lines.append(f" Unique viewers: {o['unique_viewers']}")
lines.append(f" Org rank: {rank_str}")
# Audience reach
aud = analysis["audience"]
if aud["total_with_access"] > 0 or aud.get("service_principals", 0) > 0:
lines.append("")
lines.append(f" AUDIENCE REACH (human users only)")
lines.append(f" {'─' * 40}")
lines.append(f" Users with access: {aud['total_with_access']}")
lines.append(f" Active viewers: {aud['actual_viewers']}")
if aud.get("reach_pct") is not None:
lines.append(f" Reach: {aud['reach_pct']}%")
if aud.get("service_principals", 0) > 0:
lines.append(f" Service principals: {aud['service_principals']} (excluded from reach)")
if aud["non_viewers"]:
lines.append(f" Non-viewers ({len(aud['non_viewers'])}):")
for nv in aud["non_viewers"][:10]:
lines.append(f" - {nv}")
if len(aud["non_viewers"]) > 10:
lines.append(f" ... and {len(aud['non_viewers']) - 10} more")
# Daily views
if analysis["daily_views"]:
lines.append("")
lines.append(f" DAILY VIEWS")
lines.append(f" {'─' * 40}")
max_views = max(v for _, v in analysis["daily_views"]) if analysis["daily_views"] else 1
for day, count in analysis["daily_views"]:
bar_len = int(count / max_views * 30) if max_views > 0 else 0
bar = "+" * bar_len
lines.append(f" {day} {count:>3} {bar}")
# Per-viewer breakdown
if analysis["viewers"]:
lines.append("")
lines.append(f" VIEWERS")
lines.append(f" {'─' * 40}")
lines.append(f" {'User':<35} {'Views':>5} {'Last seen':<12} Method")
for uid, stats in analysis["viewers"].items():
short_uid = uid[:33] + ".." if len(uid) > 35 else uid
last = stats["last_seen"][:10] if stats["last_seen"] else "?"
methods = ", ".join(stats["methods"]) if stats["methods"] else "?"
lines.append(f" {short_uid:<35} {stats['views']:>5} {last:<12} {methods}")
# Page views
if analysis["pages"]:
lines.append("")
lines.append(f" PAGE VIEWS")
lines.append(f" {'─' * 40}")
for pname, pdata in analysis["pages"].items():
lines.append(f" {pdata['total_views']:>4} {pname}")
if pdata.get("daily"):
for day, count in sorted(pdata["daily"].items()):
lines.append(f" {day}: {count}")
# Performance
if analysis["performance"]:
perf = analysis["performance"]
lines.append("")
lines.append(f" PERFORMANCE (n={perf['sample_count']})")
lines.append(f" {'─' * 40}")
lines.append(f" Load time P10: {perf['p10']:.1f}s")
lines.append(f" Load time P50: {perf['p50']:.1f}s")
lines.append(f" Load time P90: {perf['p90']:.1f}s")
lines.append(f" Range: {perf['min']:.1f}s - {perf['max']:.1f}s")
if perf.get("locations"):
lines.append(f" Locations:")
for loc, count in list(perf["locations"].items())[:5]:
lines.append(f" {count:>3} {loc}")
if perf.get("browsers"):
lines.append(f" Browsers:")
for browser, count in list(perf["browsers"].items())[:5]:
lines.append(f" {count:>3} {browser}")
lines.append("")
lines.append("=" * 72)
return "\n".join(lines)
#endregion
#region Main
def main():
parser = argparse.ArgumentParser(
description="Deep-dive into a single Power BI report's usage.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -w <workspace-id> -r <report-id>
%(prog)s -w <workspace-id> -r <report-id> --output json
"""
)
parser.add_argument("--workspace-id", "-w", required=True, help="Workspace GUID")
parser.add_argument("--report-id", "-r", required=True, help="Report GUID")
parser.add_argument("--region", default=DEFAULT_REGION,
choices=list(REGIONS.keys()), help=f"Power BI region (default: {DEFAULT_REGION})")
parser.add_argument("--output", "-o", choices=["table", "json"], default="table",
help="Output format (default: table)")
args = parser.parse_args()
# Authenticate
print("Authenticating...", file=sys.stderr)
token = get_token()
if not token:
sys.exit(1)
# Collect WABI data
print("Fetching report usage data...", file=sys.stderr)
data = collect_report_data(token, args.region, args.workspace_id, args.report_id)
# Get ACL
print("Fetching access control list...", file=sys.stderr)
acl = get_report_acl(args.workspace_id, args.report_id)
# Analyze
analysis = analyze_report(data, acl)
# Output
if args.output == "json":
print(json.dumps(analysis, indent=2, default=str))
else:
print(format_detail(analysis))
if __name__ == "__main__":
main()
#endregion
This is the model and report automatically generated by Power BI to compute the usage metrics. Inspect the semantic model fields if you need to extract this information. Note that you may need to first generate / refresh the usage metrics model.
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "Report",
"displayName": "Usage Metrics Report"
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000"
}
}{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definitionProperties/2.0.0/schema.json",
"version": "4.0",
"datasetReference": {
"byConnection": {
"connectionString": "Data Source=powerbi://api.powerbi.com/v1.0/myorg/spaceparts-dev;initial catalog=\"Usage Metrics Report\";integrated security=ClaimsToken;semanticmodelid=87fbab8f-2635-469d-b012-60b991e0551d"
}
}
}{
"name": "CY18SU07",
"visualStyles": {
"*": {
"*": {
"*": [
{
"wordWrap": true
}
],
"wordWrap": [
{
"show": true
}
]
}
},
"scatterChart": {
"*": {
"general": [
{
"responsive": true
}
],
"fillPoint": [
{
"show": true
}
]
}
},
"slicer": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"lineChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"waterfallChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"columnChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"clusteredColumnChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"hundredPercentStackedColumnChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"barChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"clusteredBarChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"hundredPercentStackedBarChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"areaChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"stackedAreaChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"lineClusteredColumnComboChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"lineStackedColumnComboChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"ribbonChart": {
"*": {
"general": [
{
"responsive": true
}
]
}
},
"page": {
"*": {
"outspace": [
{
"color": {
"solid": {
"color": "#FFFFFF"
}
}
}
],
"background": [
{
"transparency": 100
}
]
}
}
}
}{
"name": "City Park",
"dataColors": [
"#73B761",
"#4A588A",
"#ECC846",
"#CD4C46",
"#71AFE2",
"#8D6FD1",
"#EE9E64",
"#95DABB",
"#8FC581",
"#6E79A1",
"#F0D36B",
"#D7706B",
"#8DBFE8",
"#A48CDA",
"#F1B183",
"#AAE1C9",
"#568949",
"#384268",
"#B19635",
"#9A3935",
"#5583AA",
"#6A539D",
"#B3774B",
"#70A48C",
"#3A5C31",
"#252C45",
"#766423",
"#672623",
"#395871",
"#473869",
"#774F32",
"#4B6D5E"
],
"background": "#FFFFFF",
"foreground": "#070f25",
"tableAccent": "#0F1934"
}{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/gitIntegration/platformProperties/2.0.0/schema.json",
"metadata": {
"type": "SemanticModel",
"displayName": "Usage Metrics Report"
},
"config": {
"version": "2.0",
"logicalId": "00000000-0000-0000-0000-000000000000"
}
}{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/semanticModel/definitionProperties/1.0.0/schema.json",
"version": "4.2",
"settings": {
"qnaEnabled": true
}
}database
compatibilityLevel: 1567
expression WorkspaceId = "00000000-0000-0000-0000-000000000000" meta [IsParameterQuery = true, IsParameterQueryRequired = false, Type = "Text"]
lineageTag: 76e2006f-5b41-4df1-ae81-25129bea883c
annotation PBI_ResultType = Text
expression BaseUrl = "https://WABI-WEST-EUROPE-E-PRIMARY-redirect.analysis.windows.net" meta [IsParameterQuery = true, IsParameterQueryRequired = true, Type = "Text"]
lineageTag: e51fad98-419b-4ec2-b705-cb9160484a96
annotation PBI_ResultType = Text
model Model
culture: en-US
defaultPowerBIDataSourceVersion: powerBI_V3
sourceQueryCulture: en-US
dataAccessOptions
legacyRedirects
returnErrorValuesAsNull
annotation __PBI_TimeIntelligenceEnabled = 1
annotation PBI_QueryOrder = ["Report views","Model measures","Report rank","Report page views","Report pages","Reports","Report load times","Refresh Stats","WorkspaceId","BaseUrl"]
ref table 'Report views'
ref table DateTableTemplate_d531c5a6-f782-4ea4-a755-a9747139c561
ref table 'Model measures'
ref table 'Report rank'
ref table 'Report page views'
ref table 'Report load times'
ref table LocalDateTable_3b852b25-f44a-4909-b021-a7df8cabefe6
ref table LocalDateTable_3ebb0033-50be-4e6e-8081-ee8c7cf4091f
ref table LocalDateTable_dc7eb03a-ec8b-421f-b39b-107ea1f05736
ref table LocalDateTable_d27e4379-e50d-47cf-aeac-77eb5f688306
ref table 'Report pages'
ref table Reports
ref table LocalDateTable_e9fae86b-d80b-4c31-9ba4-095822014411
ref table 'Refresh Stats'
ref table Dates
ref table 'Workspace views'
ref table 'Workspace reports'
ref table LocalDateTable_bc207ece-a79c-4d33-a331-c40f45eb2725
ref table LocalDateTable_b465bb22-755a-4444-bc06-bd92af8a8952
ref table LocalDateTable_7e74b9e2-e4a5-4c05-8b29-c52b531c8abf
ref table Users
ref table Users_ReportPageView
ref cultureInfo en-US
relationship 04a02bc9-46bd-4135-9a6f-869c0d639cc3
joinOnDateBehavior: datePartOnly
fromColumn: 'Report load times'.StartTime
toColumn: LocalDateTable_3b852b25-f44a-4909-b021-a7df8cabefe6.Date
relationship d7b0fc44-69d8-4964-9ccd-1628d0e0e291
joinOnDateBehavior: datePartOnly
fromColumn: 'Report load times'.EndTime
toColumn: LocalDateTable_3ebb0033-50be-4e6e-8081-ee8c7cf4091f.Date
relationship ef0d9fc4-9058-4bed-a3ba-4f7948775f9d
joinOnDateBehavior: datePartOnly
fromColumn: 'Report views'.CreationTime
toColumn: LocalDateTable_dc7eb03a-ec8b-421f-b39b-107ea1f05736.Date
relationship 0a21f399-1dcb-4ce6-af26-a3f141fa16da
joinOnDateBehavior: datePartOnly
fromColumn: 'Report load times'.Timestamp
toColumn: LocalDateTable_d27e4379-e50d-47cf-aeac-77eb5f688306.Date
relationship 0c206070-358f-470d-ad74-597d2f631fe9
joinOnDateBehavior: datePartOnly
fromColumn: 'Report page views'.Timestamp
toColumn: LocalDateTable_e9fae86b-d80b-4c31-9ba4-095822014411.Date
relationship 1e2a6ce9-f344-48c3-aaf4-93840de66df6
fromColumn: 'Report load times'.ReportId
toColumn: Reports.ReportGuid
relationship 6c832a87-592b-4010-a82c-5c20fbc55660
fromColumn: 'Report views'.ReportId
toColumn: Reports.ReportGuid
relationship 00ee2abd-1580-4fd2-a72a-c7efc4d36407
fromColumn: 'Report page views'.SectionId
toColumn: 'Report pages'.SectionId
relationship bb7dbf62-05d3-4b03-92c7-b3623262de24
crossFilteringBehavior: bothDirections
fromCardinality: one
fromColumn: Reports.ReportGuid
toColumn: 'Report rank'.ReportId
relationship d41499dc-ee72-4912-9935-4661eca146cd
joinOnDateBehavior: datePartOnly
fromColumn: Dates.Date
toColumn: LocalDateTable_bc207ece-a79c-4d33-a331-c40f45eb2725.Date
relationship cae3f9e9-6304-4f88-a580-277d9bf01241
fromColumn: 'Report load times'.Date
toColumn: Dates.Date
relationship 55d869b6-3449-4a47-a0f2-20ef3fd426d1
fromColumn: 'Report views'.Date
toColumn: Dates.Date
relationship 0ca89e30-ae74-40d1-8df7-255fd44cdee9
joinOnDateBehavior: datePartOnly
fromColumn: Dates.fDoW
toColumn: LocalDateTable_b465bb22-755a-4444-bc06-bd92af8a8952.Date
relationship 250fc40c-658f-4abd-bea0-14a4d7b95417
joinOnDateBehavior: datePartOnly
fromColumn: Dates.lDoW
toColumn: LocalDateTable_7e74b9e2-e4a5-4c05-8b29-c52b531c8abf.Date
relationship 7d3d639e-6ba6-46cc-976b-9f25f5747351
fromColumn: 'Report views'.ReportId
toColumn: 'Workspace reports'.ReportGuid
relationship 0e867096-b328-44f0-9268-de15602d19a5
fromColumn: 'Report page views'.Date
toColumn: Dates.Date
relationship d0e18abb-8500-4985-a4c6-e8602ae302d3
fromColumn: 'Workspace views'.ReportId
toColumn: 'Workspace reports'.ReportGuid
relationship 0808ad5e-5a87-49d1-b679-e2b1dbf93199
toCardinality: many
fromColumn: 'Report page views'.UserKey
toColumn: Users.UserKey
relationship 9c7731bb-5867-4056-8c56-0cf1b1c8dd55
toCardinality: many
fromColumn: 'Report views'.UserKey
toColumn: Users.UserKey
relationship f952f1a3-3469-1e1c-6258-e93b78f3207e
fromColumn: 'Report page views'.ReportId
toColumn: Reports.ReportGuid