<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Luke Weaves]]></title><description><![CDATA[For Power BI estates that outgrew the admin portal. Notes on Microsoft Fabric governance, gateway operations, and tenant-scale metadata - the things the documen]]></description><link>https://lukeweaves.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a734db5eccea47f4aff3147/ed499e33-cf2e-4ecd-8a11-124e87fe9acf.png</url><title>Luke Weaves</title><link>https://lukeweaves.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 10:15:27 GMT</lastBuildDate><atom:link href="https://lukeweaves.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Managing Power BI gateways in a large tenant: the jobs the portal has no button for]]></title><description><![CDATA[Monitor gateway clusters with alerts, update credentials and access in bulk, audit who can use what, and clean up before the 1,000 limit.]]></description><link>https://lukeweaves.hashnode.dev/power-bi-gateway-admin-console</link><guid isPermaLink="true">https://lukeweaves.hashnode.dev/power-bi-gateway-admin-console</guid><category><![CDATA[Power BI]]></category><category><![CDATA[microsoft fabric]]></category><category><![CDATA[Powershell]]></category><category><![CDATA[Data Gateway]]></category><category><![CDATA[PowerBI]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Lukas Novacek]]></dc:creator><pubDate>Tue, 13 Oct 2026 14:31:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a734db5eccea47f4aff3147/78716ef7-e93e-4e3f-9c60-efcc1ee8b163.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you look after Power BI or Fabric data gateways - on-premises or virtual network (VNet) - you know Microsoft's <strong>Manage connections and gateways</strong> page. It shows the gateways and connections you manage, and lets you change them - one at a time.</p>
<p>That's fine for a handful. In a large tenant, with dozens of gateway clusters and thousands of connections, most of what a gateway admin does every week has no button there:</p>
<ul>
<li><p>putting a service account's new password on every connection that uses it</p>
</li>
<li><p>giving a team access to fifty connections at once</p>
</li>
<li><p>handing a leaver's roles to their successor</p>
</li>
<li><p>finding every connection that signs in as a given account, or that a given person can use</p>
</li>
<li><p>showing an auditor who can use what</p>
</li>
<li><p>being told when a gateway member goes down</p>
</li>
<li><p>seeing which clusters are filling up toward the limit of 1,000 data sources, and what could go</p>
</li>
</ul>
<p>Each of these ends up as hours of clicking, or a PowerShell script of your own.</p>
<p>So I built <strong>GatewayLens</strong>: a free, open-source console for gateway admins that does exactly these jobs, in bulk. It signs in as you, and can do only what you can do in the portal.</p>
<img src="https://raw.githubusercontent.com/lukas-novacek/GatewayLens/main/assets/screenshot-tour.png" alt="A tour of GatewayLens on Contoso, a fictional company" style="display:block;margin:0 auto" />

<p>Here are the six jobs it does - and, for most of them, something the docs don't tell you.</p>
<h2>1. See every gateway cluster at a glance, and know when one drops</h2>
<p>GatewayLens opens on a wall of tiles, one per gateway cluster: green when every member is online, amber when some are down, red when the cluster is offline. Each tile shows how many members are online, the gateway version, and how many connections the cluster holds against the limit of 1,000. A badge says when a cluster is near or at the limit, when a version is out of support - Microsoft supports only the last six monthly releases - or when members run different versions. Problems come first, so they are seen first, and the kiosk view puts the wall full screen on a TV for the whole team.</p>
<p>You don't have to keep looking. Turn on <strong>alerts</strong>, and GatewayLens tells you when a member goes down and when it is back: a Windows notification, a sound, or a message in Teams through a Teams workflow or a Power Automate flow - which can just as well send an email or open a ticket. A member counts as down only after two checks in a row, so one slow answer wakes nobody. Alerts come while GatewayLens is running - on that wall screen, or on your own PC.</p>
<p><strong>What the docs don't tell you:</strong> asking a virtual network (VNet) data gateway for its status starts it if it has paused - and it then uses capacity until it pauses again. GatewayLens leaves VNet gateways alone unless you ask it to check them.</p>
<img src="https://raw.githubusercontent.com/lukas-novacek/GatewayLens/main/assets/screenshot-gateways.png" alt="The gateway wall" style="display:block;margin:0 auto" />

<h2>2. Find any connection - by server, person or account</h2>
<p>The Connections tab lists every gateway connection you can see, and filters it by what you actually know when something happens:</p>
<ul>
<li><p><strong>by connection string</strong> - a server, a database, a path, or several at once: <code>sqlprd01; sqlfin01; ora-scm01</code></p>
</li>
<li><p><strong>by who has access</strong> - a person or a group, by part of their name or email</p>
</li>
<li><p><strong>by the account in the credentials</strong> - every connection that signs in as <code>svc_sales</code></p>
</li>
<li><p>by gateway, by type, and by whether its gateway is online</p>
</li>
</ul>
<p>Every button below the list then acts on exactly what is listed. And every list works like a spreadsheet: sort by any column, copy rows straight into Excel, export to CSV.</p>
<p><strong>What the docs don't tell you:</strong> the Fabric REST API doesn't say which account a connection's credentials use, and its access lists carry only object ids - no names. The portal gets the account from an endpoint of its own, and the names from Microsoft Graph. GatewayLens does the same, so you see <code>svc_sales</code> and real names instead of ids. (That endpoint isn't documented, so the account has a checkbox of its own.)</p>
<img src="https://raw.githubusercontent.com/lukas-novacek/GatewayLens/main/assets/screenshot-connections.png" alt="The Connections tab: filters, who has access, and the actions for every listed connection" style="display:block;margin:0 auto" />

<h2>3. Put a new password on every connection that uses it</h2>
<p>A service account's password changes. Filter by that account, type the new password once, and press <strong>Update credentials</strong>. GatewayLens says how many connections it will update, and to which account, and asks first. Then it updates each one, encrypted for the members of its own gateway, and tests exactly those connections afterwards - so you know straight away which still fail, with the gateway's own reason. This works for connections on on-premises gateways; VNet gateway connections are on the roadmap.</p>
<p><strong>What the docs don't tell you:</strong> update credentials through Microsoft's documented API, and the username no longer shows in the portal. That call stores only the encrypted credentials, not the readable copy of the username that the portal keeps next to them. GatewayLens updates them the way the portal does, so the username stays - and if that way is refused, it uses the documented one.</p>
<p>And with <strong>Skip test connection</strong> on, a test can pass without the gateway even trying the data source. GatewayLens marks those tests <strong>Unverified</strong>, not OK.</p>
<img src="https://raw.githubusercontent.com/lukas-novacek/GatewayLens/main/assets/screenshot-verify-status.png" alt="Verify status: every result in the log as it comes" style="display:block;margin:0 auto" />

<h2>4. Grant, review and hand over access in bulk</h2>
<ul>
<li><p><strong>Verify users</strong> shows who has access to every listed connection, by name.</p>
</li>
<li><p><strong>Grant</strong> or <strong>remove</strong> access for several people and groups at once, on every listed connection. Someone who already has the role is left alone; a lower role is raised.</p>
</li>
<li><p><strong>Hand over access</strong> when someone leaves or changes teams. <strong>Copy access</strong> gives their successor exactly the same roles on every listed connection - and, if you tick it, on the gateway clusters too. <strong>Move access</strong> does the same, then removes the old roles - but only where the successor has them by then, so no connection is ever left without an owner.</p>
</li>
</ul>
<p>Each of these asks first, naming who and how many connections, then works through them one at a time - and can be cancelled.</p>
<h2>5. Clean up before a cluster hits 1,000</h2>
<p>Microsoft allows <a href="https://learn.microsoft.com/en-us/data-integration/gateway/service-gateway-onprem">1,000 data sources per gateway cluster</a>, and a full cluster takes no new connection. Fabric now records, for each connection, when it was made, when it was last linked to an item, and when its credentials were last used. GatewayLens turns that into a cleanup:</p>
<ul>
<li><p>every tile says how many of its connections are unused - "940 of 1,000 connections - 312 unused" - and a right-click lists them</p>
</li>
<li><p><strong>Show &gt; Cleanup candidates</strong> lists what can go, each with its reason: <em>not used for 212 days</em>, or <em>no use recorded since it was made</em></p>
</li>
<li><p>copies are found too - the same gateway, type, path, account and sign-in settings. The copy used most recently stays, and a copy still in use is <em>merge first</em>, never <em>can go</em></p>
</li>
<li><p>the <strong>cleanup report</strong> is a page to forward: how full each cluster is now and after cleanup, how recently its connections were used, and who owns what can go - so you can ask before you delete</p>
</li>
<li><p>then <strong>Delete all listed connections</strong> - with a backup of every one written first</p>
</li>
</ul>
<p>Before anything goes, it helps to know what still points at it - which reports, semantic models, dataflows and pipelines would stop. I wrote about building that map for a whole tenant, with the Scanner API: <a href="https://lukeweaves.hashnode.dev/you-can-t-see-what-depends-on-your-power-bi-gateways-here-s-how-to-build-the-map">You can't see what depends on your Power BI gateways. Here's how to build the map.</a></p>
<p><strong>What the docs don't tell you:</strong> connection recency is still a preview, and use from before Microsoft began recording it doesn't show - so "never used" really means "no use recorded". And a quarterly refresh comes only every 90 days or so. That's why GatewayLens waits 120 days before it calls a connection unused, and never lists one that was made or linked in the last 30 days, or one without recency data.</p>
<img src="https://raw.githubusercontent.com/lukas-novacek/GatewayLens/main/assets/screenshot-cleanup-report.png" alt="The cleanup report" style="display:block;margin:0 auto" />

<h2>6. Answer the auditor</h2>
<p>An audit asks two things: who can use what, and who changed what.</p>
<ul>
<li><p>The <strong>access report</strong> answers the first: one row per connection or gateway and person, with the role, the connection's path and account, and when it was last used - read fresh from Fabric, as a CSV for Excel. Set the User filter first, and it's everything one person can access.</p>
</li>
<li><p>The <strong>change record</strong> answers the second. Every change GatewayLens makes is written down as it happens: when, by whom, in which tenant, what, on which connection or gateway, for whom, which role, and the result. One CSV a month, which GatewayLens never deletes. If the record can't be written, nothing is changed.</p>
</li>
<li><p>Every delete writes a backup of the connection first. No backup, no delete.</p>
</li>
</ul>
<p><strong>What the docs don't tell you:</strong> the portal leaves accounts that are no longer in Microsoft Entra ID out of its access lists - but they still hold their roles. The access report marks them, and <strong>Show &gt; Access by accounts not in Entra ID</strong> finds every connection where they still have one.</p>
<h2>Built to make mistakes hard</h2>
<p>A tool that changes credentials and access on hundreds of connections has to be careful:</p>
<ul>
<li><p>nothing changes until you press a button that says so, and every one asks first, naming what it will do and to how many</p>
</li>
<li><p>one item at a time, with every item and its result in the log, and a Cancel button while it runs</p>
</li>
<li><p>no passwords or tokens are stored, there is no telemetry, and nothing is sent anywhere but Microsoft</p>
</li>
</ul>
<h2>Questions</h2>
<h3>How do I update credentials on many Power BI gateway connections at once?</h3>
<p>Filter GatewayLens's Connections tab by the account, type the new password once, and press <strong>Update credentials</strong>. Every listed connection on an on-premises gateway gets it, and each one is tested afterwards.</p>
<h3>Can I get an alert when a gateway member goes offline?</h3>
<p>Yes. While GatewayLens runs, it shows a Windows notification, plays a sound, or posts to a Teams workflow or Power Automate flow - when a member goes down, and when it is back.</p>
<h3>How do I find what to delete before a cluster reaches 1,000 data sources?</h3>
<p><strong>Show &gt; Cleanup candidates</strong> lists the connections not used for 120 days, or never used, each with the reason. The cleanup report adds their owners, so you can ask before you delete.</p>
<h3>Does it work with VNet data gateways?</h3>
<p>Yes - their tiles, connections and access. It never wakes a sleeping VNet gateway unless you ask, and bulk credential updates are for on-premises gateway connections.</p>
<h3>Is it from Microsoft?</h3>
<p>No. GatewayLens is an independent, free and open-source project under the MIT licence. It signs in as you, and can do only what you can do in the portal.</p>
<h2>Try it</h2>
<p>GatewayLens runs on Windows with PowerShell 7.2 or later. Download <code>GatewayLens.exe</code> from the <a href="https://github.com/lukas-novacek/GatewayLens/releases/latest">latest release</a> and choose <strong>Try the demo</strong>: it opens the real app on Contoso, a fictional company, so you can try every button without signing in.</p>
<p>When you're ready for your own tenant, install Az.Accounts once - <code>Install-Module Az.Accounts -Scope CurrentUser</code>, no admin rights needed - and sign in. Run <code>GatewayLens.exe -SelfTest</code> first: it tells you what works for your account, and changes nothing.</p>
<p>GatewayLens is free and open source, under the MIT licence: <a href="https://github.com/lukas-novacek/GatewayLens">github.com/lukas-novacek/GatewayLens</a></p>
<p>If something you need is missing, open an issue - and if it saves you time, a star on GitHub helps other gateway admins find it.</p>
]]></content:encoded></item><item><title><![CDATA[Did your Oracle data refreshes slow down after the June 2026 Power BI on-prem gateway update? Here is what happened.]]></title><description><![CDATA[The June 2026 on-premises data gateway release broke a lot of Oracle connections, and people noticed within a day. The release announcement collected…]]></description><link>https://lukeweaves.hashnode.dev/did-your-oracle-data-refreshes-slow-down-after-the-june-2026-power-bi-on-prem-gateway-update-here-is-what-happened</link><guid isPermaLink="true">https://lukeweaves.hashnode.dev/did-your-oracle-data-refreshes-slow-down-after-the-june-2026-power-bi-on-prem-gateway-update-here-is-what-happened</guid><dc:creator><![CDATA[Lukas Novacek]]></dc:creator><pubDate>Sun, 16 Aug 2026 14:38:03 GMT</pubDate><content:encoded><![CDATA[<p>The June 2026 on-premises data gateway release broke a lot of Oracle connections, and people noticed within a day. The <a href="https://community.fabric.microsoft.com/t5/Fabric-Updates-Blog/On-premises-data-gateway-June-2026-release/ba-p/5233421">release announcement</a> collected dozens of comments about it: <code>ORA-12154</code> alias resolution failures, <code>ORA-50122</code> on blocked connection parameters, connections showing offline, the service refusing to start. Microsoft posted a workaround in the comments the following morning and later documented it properly.</p>
<p>Read those comments and every single reported symptom is a hard failure. Something stopped working, somebody saw an error, a ticket was raised.</p>
<p>Not one of them mentions performance.</p>
<p>That is the version of this problem I ended up with, and it took two weeks to find, because nothing failed. Refreshes completed. No errors appeared anywhere. A semantic model that had run in under thirty minutes for months was simply taking over three hours, and the only reason anyone noticed at all was that this particular extract was big enough for a four times slower read to cross the line from irritating to unusable.</p>
<p>If your Oracle connections survived the upgrade, this post is about the thing you might not have checked.</p>
<hr />
<blockquote>
<p><strong>In one paragraph:</strong> the June 2026 gateway switched Oracle connections to a <a href="https://learn.microsoft.com/en-us/power-query/connectors/oracle-database#for-import-mode-in-on-premises-gateway">built-in managed ODP.NET driver</a>. That driver ignores the <code>FetchSize</code> value in the registry and falls back to a 128 KB default. If you had tuned <code>FetchSize</code>, your tuning silently stopped applying on upgrade day. There is no error and nothing in the release notes. Microsoft documents both the behaviour and the fix. What nobody seems to be discussing is that this failure mode is invisible unless you go looking for it.</p>
</blockquote>
<p><a href="https://learn.microsoft.com/en-us/power-query/connectors/oracle-database#for-import-mode-in-on-premises-gateway">Power Query Oracle database connector - Power Query | Microsoft Learn</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a734db5eccea47f4aff3147/0e67dd75-4221-44a7-a8fa-9be89929aea6.png" alt="" style="display:block;margin:0 auto" />

<p>That chart is the whole argument, and the reason it's convincing is the second line. One connector changed. The others, on the same machines over the same days, did not.</p>
<hr />
<h2>Does this affect you?</h2>
<table>
<thead>
<tr>
<th>Check</th>
<th>How</th>
</tr>
</thead>
<tbody><tr>
<td>Gateway on June 2026 release or later</td>
<td>Admin portal, or <code>InstallDate</code> in the uninstall registry</td>
</tr>
<tr>
<td>Oracle data sources in import mode</td>
<td>You'll know</td>
</tr>
<tr>
<td>Built-in driver active</td>
<td><code>MashupFlight_DisableOracleBundledOdacProviderV2</code> reads <code>False</code></td>
</tr>
<tr>
<td><code>FetchSize</code> tuned in the registry</td>
<td>Any value under <code>HKLM:\SOFTWARE\Oracle\ODP.NET\&lt;version&gt;</code></td>
</tr>
<tr>
<td>Extracts large enough for read time to dominate</td>
<td>Hundreds of MB per query, not tens</td>
</tr>
</tbody></table>
<p>All five true means your tuning is being ignored right now. Two commands to confirm:</p>
<pre><code class="language-powershell"># Reference implementation. Read-only.
$cfg = 'C:\Program Files\On-premises data gateway\Microsoft.PowerBI.DataMovement.Pipeline.GatewayCore.dll.config'

$x = [xml](Get-Content $cfg)
"Bundled provider disabled: " + (($x.SelectNodes("//setting") |
    Where-Object { $_.name -eq 'MashupFlight_DisableOracleBundledOdacProviderV2' }).value)

Get-ChildItem 'HKLM:\SOFTWARE\Oracle\ODP.NET' -ErrorAction SilentlyContinue | ForEach-Object {
    $p = Get-ItemProperty $_.PSPath
    [PSCustomObject]@{ ProviderVersion = $_.PSChildName; FetchSize = $p.FetchSize }
} | Format-Table -AutoSize
</code></pre>
<p><code>False</code> plus a <code>FetchSize</code> value means yes.</p>
<p>One detail that makes this easy to miss: <strong>that registry path is version-keyed.</strong> A setting registered under one provider version is invisible to a different provider version. The built-in driver is a different version from anything you installed, so even before you learn it ignores the registry entirely, the version mismatch alone would have orphaned your setting. It's right there, it looks correct, and it applies to a provider that is no longer being used.</p>
<p>Microsoft documents this in the <a href="https://learn.microsoft.com/en-us/power-query/connectors/oracle-database">Oracle database connector article</a>, under "FetchSize in the registry isn't used by the built-in driver". The documentation is accurate and the fix is the same one-line config change that fixes the connection failures.</p>
<p>The gap isn't the fix. The gap is knowing you need it. Getting from "a refresh is slow" to that documentation page took two weeks, and here are the five things that cost the most time.</p>
<hr />
<h2>1. The string that changed is not the thing that changed</h2>
<p>The first theory came from the customer's DBA and it was a good one. Their audit log showed the <code>USERHOST</code> value for the connection had changed on the exact date the trouble started, from one Active Directory domain prefix to another. Conclusion: the gateway server had been moved to a different datacenter.</p>
<p>It hadn't. <code>USERHOST</code> is a client-supplied string. The Oracle client on Windows composes it from the logon domain of the process account, not from the machine's domain and certainly not from its location. The gateway service restarted as part of the upgrade, the new process got a fresh logon token, and the string changed accordingly. Same rack, same subnet, same AD site.</p>
<blockquote>
<p><strong>Anything that restarts changes a surprising number of reported values.</strong> If your investigation starts from "this value changed on the same day", check whether it's derived from process state before building a theory on it.</p>
</blockquote>
<hr />
<h2>2. On a shared gateway, the errors in the log are not your errors</h2>
<p>The logs contained plenty of Oracle errors. Duplicate transaction identifiers over a database link. Cancelled operations. Listener errors for service names that no longer existed. Every one looked like a lead.</p>
<p>None were related. All predated the upgrade by weeks. On a busy gateway environment, the log is mostly other people's problems.</p>
<p>The structural difficulty is that <code>QueryExecutionReport</code> keys on the gateway object, not on individual datasets. There's no clean join from "this error" to "that semantic model". What you do have is the <code>DataSource</code> column, which holds the connection path, so you can at least filter to the source you care about.</p>
<blockquote>
<p><strong>Before you investigate an error, establish that it's yours.</strong> Filter to your data source, then check whether its error rate actually changed on the date in question. Here, the most promising-looking errors had been running at a steady rate for months.</p>
</blockquote>
<hr />
<h2>3. The metric that shows it, and the control group that proves it</h2>
<p>Every gateway writes report files into the service account's profile:</p>
<pre><code class="language-plaintext">C:\Users\&lt;gateway service account&gt;\AppData\Local\Microsoft\On-premises data gateway\report\
</code></pre>
<p><code>QueryExecutionReport_*.log</code> is the one that matters. CSV, roughly 100 MB per file, retention measured in months. The schema is documented in <a href="https://learn.microsoft.com/en-us/data-integration/gateway/service-gateway-performance">Monitor and optimize gateway performance</a>.</p>
<p>Two columns do the work:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>What it gives you</th>
</tr>
</thead>
<tbody><tr>
<td><code>SpoolingTotalDataSize (bytes)</code></td>
<td>how much data the query moved</td>
</tr>
<tr>
<td><code>DataReadingAndSerializationDuration (ms)</code></td>
<td>how long reading it from the source took</td>
</tr>
</tbody></table>
<p>Divide one by the other for an effective read rate per query. Daily median, split by source kind, plotted over time. That's the chart at the top of this post.</p>
<p>One warning on field selection. There's a <code>DataReadingDuration (ms)</code> column in the files that isn't in the published schema. It tracks the documented field almost exactly, because serialization is under two seconds on typical queries. Use the documented one anyway. Hand a vendor numbers computed from undocumented fields and the first reply you get will be about your methodology rather than your problem.</p>
<p>Then the part that matters more than the metric itself.</p>
<blockquote>
<p><strong>A number that changed on a date is not evidence. A number that changed while a comparable number didn't is.</strong></p>
</blockquote>
<p>Same gateway, same days, same calculation, different source kind. Oracle dropped by roughly a factor of four. SQL Server didn't move, and neither did MySQL, Snowflake or SharePoint.</p>
<p>That single comparison eliminates, in one step: the network, the servers, storage, memory pressure, time of day, seasonal load, and anything else affecting the machine as a whole. Whatever changed, it changed for one connector.</p>
<p>Before reporting a regression to anyone, find something that should not have regressed and confirm it didn't.</p>
<hr />
<h2>4. A fleet median can be flat while your largest workloads are dying</h2>
<p>The obvious next move was to confirm the throughput drop in refresh durations. Power BI records every refresh, so this should have been free corroboration.</p>
<p>It wasn't. Median refresh duration for Oracle-backed datasets before and after gave a ratio of about 1.04. Essentially unchanged. The non-Oracle control gave 1.00. For an uncomfortable hour that looked like the throughput measurement was wrong.</p>
<p>It wasn't wrong. It was measuring something real that mostly didn't matter. If a model reads 20 MB, a four times slower read adds a few seconds to a refresh dominated by other work. If a model reads several hundred megabytes, the same regression turns a quarter of an hour of reading into most of an afternoon.</p>
<p>Banding datasets by baseline duration made it visible immediately: the longest-running Oracle models had roughly doubled, everything under five minutes was flat.</p>
<blockquote>
<p><strong>If your monitoring reports averages across all datasets, this entire class of problem is invisible to you.</strong> You hear about it from the one team whose extract is big enough to notice.</p>
</blockquote>
<p>This is also, I suspect, why nobody reported it publicly. The people who upgraded and lost their connections knew within hours. The people who upgraded and lost three quarters of their Oracle read throughput had nothing to see.</p>
<p>One sampling trap worth naming, because it produces a confident and meaningless number. The refresh history API returns the last N refreshes per dataset, not a time window. An older comparison window is therefore biased toward datasets that refresh infrequently, because frequent refreshers have already aged out of it. Use a symmetric window either side of your change date, and require a minimum number of runs on both sides.</p>
<hr />
<h2>5. The config edit does nothing until the service restarts</h2>
<p>This one is embarrassing.</p>
<p>Having found the documented fix, I applied it to one node and watched. Nothing moved. Not that day, not the next, not the day after. For three days it looked like the documented remedy simply didn't work, and I nearly wrote that back to Microsoft.</p>
<p>The setting was in the file. The running process was still using the value it read at startup. The service hadn't been restarted since the edit. When it eventually was, for an unrelated reason, the read rate returned to its previous level within a day and stayed there.</p>
<blockquote>
<p><strong>Verify from the running process, not from the file.</strong> If the process is older than your change, your change is not in effect, whatever the file says.</p>
</blockquote>
<pre><code class="language-powershell">Get-CimInstance Win32_Process -Filter "Name='Microsoft.PowerBI.EnterpriseGateway.exe'" |
    Select-Object ProcessId, CreationDate     # must be later than your edit
</code></pre>
<hr />
<h2>Putting the pieces together</h2>
<p>The whole measurement in one place. Reference implementation, not a drop-in solution. It reads the gateway's own report files, works out your upgrade date from the registry, and produces a daily median read rate per source kind. It changes nothing.</p>
<pre><code class="language-powershell"># =============================================================================
# Gateway read throughput, daily median by source kind, before vs after upgrade
# Read-only. No configuration is touched.
# =============================================================================

param(
    [string]  $ReportDir,
    [datetime]$ChangeDate,       # leave empty to derive from the gateway install date
    [int]     $MinSpoolMB = 5    # ignore small queries: metadata calls swamp the stats
)

# --- when was this gateway last upgraded -------------------------------------
if (-not $ChangeDate) {
    $gw = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                           'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
          Where-Object { $_.DisplayName -eq 'GatewayComponents' } | Select-Object -First 1
    if (-not $gw.InstallDate) { throw 'Could not determine upgrade date. Pass -ChangeDate explicitly.' }
    $ChangeDate = [datetime]::ParseExact($gw.InstallDate, 'yyyyMMdd', $null)
    Write-Host "Gateway components $($gw.DisplayVersion), installed $($ChangeDate.ToString('yyyy-MM-dd'))"
}
$cut = $ChangeDate.ToString('yyyy-MM-dd')

# --- find the report directory -----------------------------------------------
# It lives under the profile of the account the gateway service runs as,
# which is usually not the account you are logged in with.
if (-not $ReportDir) {
    $svc  = Get-CimInstance Win32_Service -Filter "Name='PBIEgwService'"
    $acct = ($svc.StartName -split '[@\\]')[0]
    $prof = Get-ChildItem 'C:\Users' -Directory |
            Where-Object { $_.Name -like "*$acct*" } | Select-Object -First 1
    $ReportDir = Join-Path $prof.FullName 'AppData\Local\Microsoft\On-premises data gateway\report'
}

$files = Get-ChildItem "$ReportDir\QueryExecutionReport_*.log" | Sort-Object Name
if (-not $files) { throw "No query execution reports found in $ReportDir" }

$hdr      = (Get-Content $files[0].FullName -TotalCount 1) -split ','
$minBytes = $MinSpoolMB * 1MB
$rows     = New-Object System.Collections.Generic.List[object]

foreach ($f in $files) {
    Write-Host "parsing $($f.Name)" -ForegroundColor DarkGray

    # Stream it. These files are ~100 MB each and you do not want them in memory.
    $reader = [System.IO.StreamReader]::new($f.FullName)
    $null = $reader.ReadLine()                        # header

    while ($null -ne ($line = $reader.ReadLine())) {

        # Cheap prefilter: a line with no large number cannot pass the size test.
        if ($line -notmatch '\d{7,}') { continue }

        try {
            $r     = $line | ConvertFrom-Csv -Header $hdr
            $spool = [double]($r.'SpoolingTotalDataSize(byte)' -replace '^$','0')
            if ($spool -lt $minBytes) { continue }

            $dur = [double]($r.'DataReadingAndSerializationDuration(ms)' -replace '^$','0')
            if ($dur -le 0) { continue }
            if ($r.QueryExecutionEndTimeUTC -notmatch '^\d{4}') { continue }

            # DataSource holds escaped JSON. After ConvertFrom-Csv has stripped one
            # layer of quoting it reads:
            #   ["{\"kind\":\"Oracle\",\"path\":\"host:1521/service\"}"]
            # so SINGLE backslashes here. See the note below.
            $kind = ''
            $m = [regex]::Match($r.DataSource, 'kind\\":\\"([^\\]+)')
            if ($m.Success) { $kind = $m.Groups[1].Value }

            $rows.Add([PSCustomObject]@{
                Day      = $r.QueryExecutionEndTimeUTC.Substring(0,10)
                Kind     = $kind
                SpoolMB  = [math]::Round($spool / 1MB, 2)
                ReadSec  = [math]::Round($dur / 1000, 1)
                ExecSec  = [math]::Round([double]($r.'QueryExecutionDuration(ms)' -replace '^$','0') / 1000, 1)
                MBperSec = [math]::Round(($spool / 1MB) / ($dur / 1000), 3)
            })
        } catch { }
    }
    $reader.Close()
}

function Get-Median([object[]]$v) {
    if (-not $v -or $v.Count -eq 0) { return 0 }
    $s = $v | Sort-Object
    if ($s.Count % 2) { $s[[int](($s.Count - 1) / 2)] }
    else { ($s[$s.Count/2 - 1] + $s[$s.Count/2]) / 2 }
}

# --- daily trend: your suspect connector plus its control group ---------------
$rows | Group-Object Day, Kind | ForEach-Object {
    $p = $_.Name -split ', '
    [PSCustomObject]@{
        Day        = $p[0]
        Kind       = $p[1]
        Queries    = $_.Count
        MedMBps    = [math]::Round((Get-Median ($_.Group.MBperSec)), 3)
        AvgSpoolMB = [math]::Round(($_.Group | Measure-Object SpoolMB -Average).Average, 1)
        AvgExecSec = [math]::Round(($_.Group | Measure-Object ExecSec -Average).Average, 1)
    }
} | Sort-Object Day, Kind | Format-Table -AutoSize

# --- one-line verdict per source kind ----------------------------------------
$rows | Group-Object Kind | ForEach-Object {
    $b = Get-Median (($_.Group | Where-Object { $_.Day -lt  $cut }).MBperSec)
    $a = Get-Median (($_.Group | Where-Object { $_.Day -ge $cut }).MBperSec)
    [PSCustomObject]@{
        Kind   = $_.Name
        Before = [math]::Round($b, 3)
        After  = [math]::Round($a, 3)
        Ratio  = if ($b -gt 0) { [math]::Round($a / $b, 2) } else { $null }
    }
} | Sort-Object Ratio | Format-Table -AutoSize
</code></pre>
<p>Notes on the shape of it.</p>
<p><strong>Median, not mean.</strong> One query with a read time of a few milliseconds produces an absurd rate that drags the average around. The median held to within a few percent day to day for three months, which is what made a step change obvious rather than arguable.</p>
<p><strong>The 5 MB floor is doing real work.</strong> Without it, thousands of sub-second metadata and preview queries dominate the counts and the signal disappears.</p>
<p><code>AvgSpoolMB</code> <strong>is your sanity check.</strong> It should be roughly flat across your change date. If average payload per query grew, you have a data volume story rather than a driver story.</p>
<p><code>AvgExecSec</code> <strong>is what lets you release the DBA.</strong> That's <code>QueryExecutionDuration</code>, the database executing the query. If execution time is flat and read time has grown, the database is doing its job at the same speed and the problem is in getting results back. Demonstrating that saved several days of database-side investigation that would have found nothing.</p>
<p><strong>The escaped JSON will cost you an hour if you let it.</strong> How many backslashes you need depends on whether you've parsed the CSV yet:</p>
<pre><code class="language-powershell"># Raw file text, before parsing: backslashes are DOUBLED
Select-String -Path $file -Pattern 'kind\\"":\\""Oracle'

# After ConvertFrom-Csv: one layer stripped, SINGLE backslashes
$parsed.DataSource -match 'kind\\":\\"Oracle'
</code></pre>
<p>Wrong in either direction gives zero rows and no error, which looks exactly like "there is no Oracle traffic on this gateway". I lost time to this twice, in both directions. If you'd rather not think about it, match the plain word and filter after parsing.</p>
<hr />
<h2>The fix</h2>
<p>Opting out of the built-in driver returns you to the ODP.NET provider from your installed Oracle client, which does honour the registry <code>FetchSize</code>. Microsoft <a href="https://learn.microsoft.com/en-us/power-query/connectors/oracle-database">documents the procedure</a>: back up the config, set the flag to <code>True</code>, restart the gateway.</p>
<pre><code class="language-powershell"># Reference implementation. This CHANGES CONFIGURATION.
# Read the documentation first. Requires administrative rights.

$cfg = 'C:\Program Files\On-premises data gateway\Microsoft.PowerBI.DataMovement.Pipeline.GatewayCore.dll.config'

Copy-Item $cfg "$cfg.bak-$(Get-Date -f yyyyMMdd-HHmmss)" -Force   # back up first, always

Stop-Service PBIEgwService -Force        # the running process holds the old value
Start-Sleep -Seconds 10

$x = [xml](Get-Content $cfg)             # edit as XML, not text, so you cannot mangle it
$node = $x.SelectNodes("//setting") |
        Where-Object { $_.name -eq 'MashupFlight_DisableOracleBundledOdacProviderV2' }
if (-not $node) { Start-Service PBIEgwService; throw 'Setting not found. Aborted, nothing changed.' }
$node.value = 'True'
$x.Save($cfg)

Start-Service PBIEgwService
Start-Sleep -Seconds 30

$v = [xml](Get-Content $cfg)
"Flag on disk: " + (($v.SelectNodes("//setting") |
    Where-Object { $_.name -eq 'MashupFlight_DisableOracleBundledOdacProviderV2' }).value)
Get-CimInstance Win32_Process -Filter "Name='Microsoft.PowerBI.EnterpriseGateway.exe'" |
    Select-Object ProcessId, CreationDate     # must be later than your edit
</code></pre>
<p>Two things worth knowing. <strong>This setting survives gateway upgrades</strong>, which Microsoft states explicitly and which is unusual for anything in that directory. And <strong>give it a full day before judging the result</strong>, because a single refresh tells you very little.</p>
<hr />
<h2>The loud failures, for completeness</h2>
<p>If your Oracle connections broke outright rather than slowing down, the same flag fixes most of it, but two other causes surfaced in the community <a href="https://community.fabric.microsoft.com/t5/Fabric-Updates-Blog/On-premises-data-gateway-June-2026-release/ba-p/5233421">thread</a> and neither is obvious from the error text.</p>
<p><code>TNS_ADMIN</code> <strong>must be set at machine scope.</strong> The gateway service account cannot see user-scoped environment variables, so a <code>TNS_ADMIN</code> set for your own login resolves nothing for the service. Set it at system scope, point it at the directory containing <code>tnsnames.ora</code>, restart the service. Several people hit <code>ORA-12154</code> for this reason, and the error is misleading: the driver was not failing to find the alias, it was failing to find the file.</p>
<p><strong>Some previously ignored TNS parameters are now rejected.</strong> <code>UR=A</code> in a <code>tnsnames.ora</code> connect descriptor is the documented example. Older clients ignored it, the built-in managed driver rejects it with <code>ORA-50122: The following configuration parameters are not allowed to be set</code>. If a connection descriptor has accumulated optional parameters over the years, that error names which one is the problem. Removing it is usually safe, but check why it was there first.</p>
<p>Two other documented differences are worth knowing even if nothing is failing, because neither necessarily errors:</p>
<ul>
<li><p><code>NLS_LANG</code> <strong>is ignored.</strong> The built-in provider takes language, territory, and number and date formats from the machine's .NET culture instead. Depending on regional settings that can change how values parse. Sometimes it errors. Sometimes it returns quietly different numbers, which is worse.</p>
</li>
<li><p><strong>Oracle proxy-user authentication is not supported.</strong> Connections in the <code>User Id=proxy_user[target_schema]</code> form fail.</p>
</li>
</ul>
<hr />
<p>The Oracle specifics here will be fixed eventually and this post will become a historical curiosity. The shape underneath it won't be.</p>
<p>A managed platform component changed a dependency underneath me. My tuning lived outside that component and applied to the old dependency. When the component swapped, the tuning was still present, still valid-looking, and doing nothing at all. There is no error state for "this setting is being ignored", and no screen anywhere would have told me.</p>
<p>What strikes me most, having read every comment on that release announcement, is the selection effect. The failures got reported within a day because failure is loud. The silent version of the same change, on the same date, from the same root cause, appears nowhere. That does not mean nobody has it. It means the only people who find it are the ones who were already measuring, or the ones with a workload big enough to make four times slower into a business problem.</p>
<p>The only thing that found it here was a ratio, computed the same way before and after, with a control group next to it.</p>
<p>If you're doing gateway dependency work more generally, the <a href="https://lukeweaves.hashnode.dev">dependency map from my previous post</a> is what turns "one team complained" into "here is every artifact on that connection and who owns it".</p>
<hr />
<p><em>If you upgraded in June and your Oracle connections kept working, I'd be curious whether anyone has actually checked their read throughput since. And if anyone has found a supported way to configure fetch size for the built-in managed provider, rather than opting out of it entirely, I'd like to hear it.</em></p>
]]></content:encoded></item><item><title><![CDATA[You can't see what depends on your Power BI gateways. Here's how to build the map.]]></title><description><![CDATA[What the admin portal can't tell you about your gateway dependencies, and how to build the map yourself.]]></description><link>https://lukeweaves.hashnode.dev/you-can-t-see-what-depends-on-your-power-bi-gateways-here-s-how-to-build-the-map</link><guid isPermaLink="true">https://lukeweaves.hashnode.dev/you-can-t-see-what-depends-on-your-power-bi-gateways-here-s-how-to-build-the-map</guid><dc:creator><![CDATA[Lukas Novacek]]></dc:creator><pubDate>Wed, 05 Aug 2026 15:30:46 GMT</pubDate><content:encoded><![CDATA[<p>An email arrives from infrastructure: a database server is being decommissioned in six weeks. They need to know what's using it.</p>
<p>You open the Fabric admin portal. You can see your gateway clusters. You can click into one and see the data sources configured on it. You can see which users have access to each one.</p>
<p>What you cannot see, anywhere, in any screen, is the answer to the question you were actually asked: <strong>which reports, datasets, dataflows and pipelines depend on this connection?</strong></p>
<p>That inversion is the gap. Power BI shows you gateway to data source. It does not show you data source to everything that uses it. And the second direction is the one that every operational question needs.</p>
<p>I recently built that map for a large enterprise tenant: many thousands of workspaces, and a great many more links between artifacts and the gateway data sources they consume. This post covers why it's worth having, and the five things that cost me the most time building it. One of them meant my original numbers were substantially overstated, in a direction that flattered the estate.</p>
<hr />
<h2>Why the map is worth building</h2>
<p>If you manage a small estate, you hold the dependencies in your head. Past a few hundred data sources you don't, and the cost of not knowing shows up in specific, expensive ways.</p>
<h3>You're changing things blind</h3>
<p>Every credential rotation, server migration and data source cleanup is currently a guess. You make the change, then you find out what depended on it when the complaints arrive. Your feedback loop runs through your users, which is the most expensive possible way to discover a dependency.</p>
<p>With the map, "what breaks if I touch this?" becomes a query that answers in a second, before you touch anything.</p>
<h3>Incident response runs backwards</h3>
<p>A data source goes offline at 3am. Right now the sequence is: refreshes fail silently, users notice broken reports over the following hours, tickets arrive, and eventually someone correlates them into a cause.</p>
<p>With a dependency map the sequence inverts. The moment a data source fails you can list every affected artifact and every owner, and send one message before the first ticket. Same outage, completely different experience for the business, and a much shorter time to <em>communicate</em>, which is often what people actually judge you on.</p>
<h3>Cleanup is too risky to attempt</h3>
<p>Most large tenants accumulate hundreds of data sources nobody can account for. Created for a proof of concept, left behind by someone who changed teams, duplicated because that was faster than finding the original.</p>
<p>Everyone knows they should go. Nobody removes them, because the downside of deleting something still in use vastly outweighs the benefit of tidiness. So they sit there for years, cluttering the portal and quietly holding credentials to production systems.</p>
<p>The map turns that from a judgement call into a fact. A data source with no dependent artifacts, no assigned users, and no successful connection in ninety days is not a risk to delete. It's a liability to keep.</p>
<p>That last criterion needs its own note, because the platform will not give it to you. The API can tell you whether a data source is reachable <em>right now</em>, but it keeps no history. And "unreachable right now" is close to useless for cleanup decisions, because a source that failed this morning during a database restart looks identical to one that has been dead since last year.</p>
<p>To tell those apart you have to build the history yourself: snapshot the status of every data source on a schedule, write it to a table with a timestamp, and let it accumulate. After a few months you can ask the question that actually matters, which is whether this connection has succeeded even once in the last ninety days. That's a very different and much safer question than whether it happens to be up at this moment.</p>
<h3>Decommissioning and migration are unplannable</h3>
<p>Server retirements, gateway consolidation, moves to VNet gateways, Fabric migrations. All of them start with "what's affected, and who owns it?" Without the map, the honest answer is an email to all report owners asking them to check, which produces a partial response and a lingering suspicion that something was missed. Usually because it was.</p>
<h3>Nobody can audit access to on-prem systems through BI</h3>
<p>Your gateway data sources hold credentials to production databases and file shares. The chain from "this person can view a report" back through which dataset, on which gateway, using which service account, against which server, is not visible in any single place. For anyone asking compliance questions, the map is the only way to answer honestly.</p>
<hr />
<h2>What the map actually is</h2>
<p>One flat table, one row per artifact-to-datasource relationship:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>artifact_type</code></td>
<td>dataset, dataflow, datapipeline, notebook, lakehouse, and so on</td>
</tr>
<tr>
<td><code>workspace_id</code> / <code>workspace_name</code></td>
<td>where it lives</td>
</tr>
<tr>
<td><code>item_id</code> / <code>item_name</code></td>
<td>the artifact itself</td>
</tr>
<tr>
<td><code>datasource_id</code></td>
<td>the connection it uses</td>
</tr>
<tr>
<td><code>datasource_type</code></td>
<td>Sql, Oracle, File, Web, Extension</td>
</tr>
<tr>
<td><code>gateway_id</code></td>
<td>the gateway the connection is bound to</td>
</tr>
<tr>
<td><code>connection_details</code></td>
<td>server, database, path, URL</td>
</tr>
<tr>
<td><code>scan_date</code> / <code>scan_run_id</code></td>
<td>when this was true</td>
</tr>
</tbody></table>
<p>That's the whole thing. Every question above becomes a <code>WHERE</code> clause against this table. The value isn't in the schema, it's in the fact that no UI can produce this table, and the API that can has a few behaviours that will cost you time.</p>
<p>Here are the five that cost me the most.</p>
<p>A note on the code below: the Scanner API calls are real, but the flags, helper functions and table names are my own. There is no built-in resume switch or safety threshold. Those are patterns you implement yourself, and I've named them here the way I named them in my own notebook.</p>
<hr />
<h2>1. The parameter combination that returns nothing, silently</h2>
<p>If you only care about gateway relationships, the obvious optimisation is to narrow the scan. Skip dataset schemas, skip expressions, ask only for what you need. Less data over the wire, faster runs, smaller payloads.</p>
<p>It works. It returns quickly. And <code>datasourceInstances</code> and <code>datasourceUsages</code> come back <strong>empty</strong>.</p>
<p>Datasource details are only populated when <strong>lineage is enabled</strong>. Turn lineage off and you get a fast, clean, entirely useless result, with no error and no warning that you've asked for something the API won't give you in that configuration.</p>
<pre><code class="language-python"># The combination that actually returns datasource details
scan_workspaces(
    workspaces=batch,
    lineage=True,               # REQUIRED. Without this, no datasourceInstances
    datasource_details=True,
    dataset_schema=False,       # safe to skip, large payload reduction
    dataset_expressions=False,  # safe to skip
)
</code></pre>
<p>The failure mode is what makes this expensive. An empty array looks exactly like "this tenant has no gateway-bound artifacts", so I spent a long time questioning my transformation logic before I thought to question the request.</p>
<p>If you want datasource information, <code>lineage=True</code> is not optional, no matter how narrow you think your scan is.</p>
<hr />
<h2>2. Stage the raw response before you transform it</h2>
<p>Scanner responses for a large tenant get big enough to cause problems if you try to hold everything in memory and write it in one pass.</p>
<p>The fix is to write the raw scan output to storage first, and transform from there as a separate step. <code>write_jsonl</code> below is just a stand-in for whatever you use to append text files to your lakehouse:</p>
<pre><code class="language-python"># Phase 1: scan, and immediately persist the raw result
for batch in workspace_batches:
    result = scan_workspaces(batch, lineage=True, datasource_details=True)
    write_jsonl(f"Files/scan_staging/scan_{batch_id}.jsonl", result)

# Phase 2: transform from staging, never from the API
df = spark.read.json("Files/scan_staging/*.jsonl")
</code></pre>
<p>The obvious benefit is memory. The real benefit is bigger.</p>
<p>A full tenant scan takes a long time. Your transformation logic <strong>will</strong> fail on the first several attempts, because of an unexpected null, a type that varies between artifact kinds, or a nested field you didn't anticipate. If your transform reads from the API, every one of those failures costs you another complete scan. If it reads from staging, it costs you seconds.</p>
<p>That single decision turned my iteration loop from an hour into a moment, and it's the habit I'd carry into any similar project on any platform. It also means you can add your own resume switch, so you can rebuild the output tables from staged files without touching the API again:</p>
<pre><code class="language-python"># my own flag, not an API feature
RESUME_FROM_STAGING = True   # rebuild output tables from staged JSON, zero API calls
</code></pre>
<p>Once that exists you can restructure your schema as often as you like, for free.</p>
<hr />
<h2>3. Flatten your structs or Delta will refuse the merge</h2>
<p>Scanner output is deeply nested. Workspaces contain datasets, which contain datasource usages, which reference datasource instances whose connection details vary by type.</p>
<p>Write that structure directly to Delta across multiple batches and you'll eventually hit a schema merge failure. Different batches infer subtly different schemas, because a field that's absent in one batch appears in another, and nested struct types don't reconcile cleanly.</p>
<p>Flatten aggressively to scalar columns, and keep the original JSON alongside:</p>
<pre><code class="language-python">df_flat = df.select(
    F.col("id").alias("workspace_id"),
    F.col("name").alias("workspace_name"),
    F.col("datasource.datasourceId").alias("datasource_id"),
    F.col("datasource.gatewayId").alias("gateway_id"),
    F.col("datasource.datasourceType").alias("datasource_type"),
    # keep the full structure for anything you didn't anticipate
    F.to_json(F.col("datasource.connectionDetails")).alias("connection_details"),
)
</code></pre>
<p>A flat table of scalars plus a JSON column merges cleanly every time, stays queryable from SQL, and loses nothing. When you eventually need a field you didn't flatten, parse it out of the JSON rather than rebuilding your schema.</p>
<hr />
<h2>4. Put a guard between the scan and the load</h2>
<p>This is the failure mode that should worry you most, because it produces no error at all.</p>
<p>Your scan runs on a schedule and feeds a table that reporting and cleanup processes depend on. One night it half fails, from throttling or a token expiry or a transient error, and returns a small fraction of your workspaces. Your load step truncates the target table and writes the partial result.</p>
<p>Nothing errored. Every downstream process now believes most of your estate doesn't exist. If anything automated acts on that data, for example a cleanup job that deletes data sources with no dependencies, the consequences escalate quickly.</p>
<p>There is no built-in protection against this. You have to add it yourself, and it costs about three lines:</p>
<pre><code class="language-python"># my own guard. Set these well below your true numbers,
# but high enough to catch a badly broken run.
MIN_EXPECTED_WORKSPACES = &lt;your_baseline&gt;
MIN_EXPECTED_LINKS      = &lt;your_baseline&gt;

if workspace_count &lt; MIN_EXPECTED_WORKSPACES:
    raise Exception(
        f"Only {workspace_count} workspaces scanned (expected &gt;= {MIN_EXPECTED_WORKSPACES}). "
        f"Aborting before load, refusing to overwrite good data with a partial scan."
    )
</code></pre>
<p>Fail loudly and leave yesterday's good data in place. A missed refresh is an inconvenience. A silently truncated inventory that automated processes then act on is an incident.</p>
<p>The principle generalises well beyond this: any pipeline that replaces rather than appends needs a sanity check on what it's about to destroy.</p>
<hr />
<h2>5. "Bound to a gateway" does not mean "on-premises"</h2>
<p>This is the finding that changed how I read every number the scan produced.</p>
<p>The intuitive reading of <code>gatewayId IS NOT NULL</code> is "this connection goes through an on-premises data gateway". It doesn't. <strong>Power BI assigns a</strong> <code>gatewayId</code> <strong>to cloud connections as well</strong>, including SharePoint, web sources, cloud-hosted databases and Databricks.</p>
<p>So a raw count of gateway-bound artifacts substantially overstates your on-premises footprint, in my case by a wide margin.</p>
<p>To get a truthful number you have to join back to your own gateway inventory and separate real clusters from cloud connections. The table names below are mine, so substitute your own:</p>
<pre><code class="language-sql">SELECT COUNT(*) AS on_prem_links
FROM   fabric_gateway_usage f
JOIN   gateway_inventory     g ON g.GatewayID = f.gateway_id
WHERE  g.GatewayName IS NOT NULL
  AND  LTRIM(RTRIM(g.GatewayName)) &lt;&gt; '';   -- blank name means a cloud connection
</code></pre>
<p>This matters more than it first appears. If you're presenting dependency figures to anyone making decisions about gateway consolidation, licensing, infrastructure investment or migration scope, an inflated count leads to a materially different conversation than the real one. Halving a headline number after someone has already planned around it is a bad look, and an easy one to avoid.</p>
<p>It's worth checking whether any dashboard you already publish is quietly making this mistake.</p>
<hr />
<h2>Putting it to work</h2>
<p>With a complete, trustworthy map in a queryable table, the questions that were previously unanswerable become one-liners.</p>
<p><strong>What breaks if I remove this data source?</strong></p>
<pre><code class="language-sql">SELECT artifact_type, workspace_name, item_name
FROM   fabric_gateway_usage
WHERE  datasource_id = @datasource_id;
</code></pre>
<p><strong>Which data sources can I safely delete?</strong> The ones with no dependent artifacts at all, and if you've been snapshotting status over time as described earlier, the ones that haven't connected successfully in months either.</p>
<p><strong>Which gateways actually carry production load?</strong> Count distinct artifacts per gateway, rather than guessing from the name.</p>
<p><strong>Who do I notify about this outage?</strong> Join the affected artifacts to their workspace owners, and send one message before the tickets start.</p>
<hr />
<h2>Putting the pieces together</h2>
<p>The sections above are fragments, so here is the whole flow in one place. This is a reference implementation, not a drop-in solution: paths, batch sizes and thresholds depend on your environment, and you'll want to adapt the flattening to the artifact types you care about.</p>
<p>It runs as a single Fabric notebook with a lakehouse attached. One prerequisite that isn't obvious: <code>%pip install</code> is disabled when a notebook runs inside a pipeline, so attach a Fabric Environment with <code>semantic-link-labs</code> as a library instead. Installing only <code>semantic-link-sempy</code> is not enough, because the admin scan functions live in the labs package.</p>
<pre><code class="language-python"># =============================================================================
# Tenant-wide gateway dependency map
# Scans all workspaces, stages raw JSON, flattens to a queryable table.
# =============================================================================

from pyspark.sql import functions as F
from pyspark.sql.types import StructType
import json, uuid, datetime

# ----- configuration ---------------------------------------------------------
BATCH_SIZE          = 90        # workspaces per scan call
STAGING_PATH        = "Files/scan_staging"
TARGET_TABLE        = "fabric_gateway_usage"

RESUME_FROM_STAGING = False     # True = rebuild tables from staged JSON, no API calls
KEEP_HISTORY        = True      # False = keep only the latest run

# safety guards. Set well below your true numbers, high enough to catch a bad run.
MIN_EXPECTED_WORKSPACES = 0     # e.g. 80% of your normal workspace count
MIN_EXPECTED_LINKS      = 0     # e.g. 80% of your normal link count

SCAN_RUN_ID = str(uuid.uuid4())
SCAN_DATE   = datetime.datetime.utcnow()


# ----- phase 1: scan and stage ----------------------------------------------
def run_scan():
    """Scan every workspace and write raw responses to the lakehouse."""
    import sempy_labs.admin as admin          # lazy import: resume runs skip this

    workspaces = admin.list_workspaces()
    ws_ids     = [w for w in workspaces["Id"]]
    print(f"{len(ws_ids)} workspaces to scan")

    for i in range(0, len(ws_ids), BATCH_SIZE):
        batch    = ws_ids[i:i + BATCH_SIZE]
        batch_no = i // BATCH_SIZE

        result = admin.scan_workspaces(
            workspace=batch,
            lineage=True,               # REQUIRED for datasource details
            datasource_details=True,
            dataset_schema=False,
            dataset_expressions=False,
        )

        # stage immediately: a later failure must not cost another scan
        path = f"/lakehouse/default/{STAGING_PATH}/scan_{batch_no:05d}.json"
        with open(path, "w", encoding="utf-8") as f:
            json.dump(result, f)

        print(f"  batch {batch_no}: {len(batch)} workspaces staged")


# ----- phase 2: flatten ------------------------------------------------------
def build_links():
    """Read staged JSON and flatten to one row per artifact-to-datasource link."""
    raw = spark.read.option("multiline", "true").json(f"{STAGING_PATH}/*.json")

    # datasource instances: id -&gt; type + connection details
    instances = (
        raw.select(F.explode("datasourceInstances").alias("d"))
           .select(
               F.col("d.datasourceId").alias("datasource_id"),
               F.col("d.datasourceType").alias("datasource_type"),
               F.col("d.gatewayId").alias("gateway_id"),
               # keep the raw structure: connection shape varies by type
               F.to_json(F.col("d.connectionDetails")).alias("connection_details"),
           )
           .dropDuplicates(["datasource_id"])
    )

    frames = []

    # each artifact type sits in its own array on the workspace object
    for artifact_type, array_col in [
        ("dataset",          "datasets"),
        ("dataflow",         "dataflows"),
        ("datapipeline",     "datapipelines"),
        ("lakehouse",        "lakehouses"),
        ("notebook",         "notebooks"),
    ]:
        if array_col not in raw.select("workspaces.*").columns:
            continue

        frames.append(
            raw.select(F.explode("workspaces").alias("w"))
               .select("w.id", "w.name", F.explode(f"w.{array_col}").alias("item"))
               .select(
                   F.lit(artifact_type).alias("artifact_type"),
                   F.col("id").alias("workspace_id"),
                   F.col("name").alias("workspace_name"),
                   F.col("item.id").alias("item_id"),
                   F.col("item.name").alias("item_name"),
                   F.explode("item.datasourceUsages").alias("usage"),
               )
               .select("*", F.col("usage.datasourceInstanceId").alias("datasource_id"))
               .drop("usage")
        )

    if not frames:
        raise Exception("No artifacts found in staged data. Check lineage=True.")

    links = frames[0]
    for f_ in frames[1:]:
        links = links.unionByName(f_, allowMissingColumns=True)

    return (
        links.join(instances, on="datasource_id", how="left")
             .withColumn("scan_date",   F.lit(SCAN_DATE))
             .withColumn("scan_run_id", F.lit(SCAN_RUN_ID))
    )


# ----- phase 3: guard, then load --------------------------------------------
def load(links):
    """Refuse to publish a partial scan over good data."""
    ws_count   = links.select("workspace_id").distinct().count()
    link_count = links.count()
    print(f"{ws_count} workspaces, {link_count} links")

    if ws_count &lt; MIN_EXPECTED_WORKSPACES:
        raise Exception(
            f"Only {ws_count} workspaces (expected &gt;= {MIN_EXPECTED_WORKSPACES}). "
            f"Aborting: refusing to overwrite good data with a partial scan."
        )
    if link_count &lt; MIN_EXPECTED_LINKS:
        raise Exception(
            f"Only {link_count} links (expected &gt;= {MIN_EXPECTED_LINKS}). Aborting."
        )

    mode = "append" if KEEP_HISTORY else "overwrite"
    (links.write
          .format("delta")
          .mode(mode)
          .option("mergeSchema", "true")
          .saveAsTable(TARGET_TABLE))

    # a view exposing only the current run, for downstream consumers
    spark.sql(f"""
        CREATE OR REPLACE VIEW {TARGET_TABLE}_latest AS
        SELECT * FROM {TARGET_TABLE}
        WHERE  scan_run_id = (
            SELECT scan_run_id FROM {TARGET_TABLE}
            ORDER BY scan_date DESC LIMIT 1
        )
    """)
    print(f"Loaded into {TARGET_TABLE} (mode={mode})")


# ----- run -------------------------------------------------------------------
if not RESUME_FROM_STAGING:
    run_scan()

load(build_links())
</code></pre>
<p>A few notes on the shape of it.</p>
<p><strong>The scan and the transform are separate functions on purpose.</strong> That's what makes <code>RESUME_FROM_STAGING</code> possible, and it's the difference between a one minute iteration loop and an hour one.</p>
<p><strong>The import of</strong> <code>sempy_labs.admin</code> <strong>sits inside</strong> <code>run_scan</code><strong>.</strong> On a resume run you never touch the API, so there's no reason to pay the import cost.</p>
<p><strong>The guards run before the write, not after.</strong> That ordering is the entire point: yesterday's complete data survives a bad scan today.</p>
<p><strong>The</strong> <code>_latest</code> <strong>view exists because the table accumulates.</strong> With <code>KEEP_HISTORY = True</code> you get every run, which is useful for tracking how dependencies change over time. Downstream consumers that only want the current picture read the view instead of the table.</p>
<p>Adapt the artifact type list to what you care about. Scanner output includes more types than the five above, and the exact set shifts as Fabric adds item types, so treat that loop as the place you'll be editing most often.</p>
<hr />
<p>None of this is exotic once the map exists. Building it correctly is the hard part, and most of that difficulty turned out not to be volume or performance. It was five behaviours that no documentation warned me about, one of which meant my numbers were wrong in a direction that made the estate look better than it was.</p>
<hr />
<p><em>If you're running Scanner API scans at scale I'd be interested in what you've hit, particularly throttling behaviour on very large tenants, and whether anyone has found a cleaner way to distinguish cloud from on-prem connections than joining back to the gateway inventory.</em></p>
]]></content:encoded></item></channel></rss>