Logged queries (workspace monitoring)
An annotation declares a consumer; a query log observes one. With workspace
monitoring
enabled, Fabric records every query a semantic model serves — report visuals, Excel
pivots (MDX), DAX Studio, XMLA clients — in the SemanticModelLogs table of the
workspace's monitoring Eventhouse. Export the QueryEnd events and pass the file:
SemanticModelLogs
| where OperationName == "QueryEnd" and ItemName == "AdventureWorks Sales"
| where Timestamp > ago(30d)
| project Timestamp, OperationName, OperationDetailName, ItemId, ItemName,
ApplicationName, ApplicationContext, ExecutingUser, EventText
ripbi scan --queries-from exports/semantic-model-logs.csv
- Every object a logged query names directly is a root, like a report binding:
it is live, and so is everything it uses. DAX queries resolve through the same
binder as model DAX (query-local
DEFINE MEASURE/VARnames resolve to nothing); MDX queries name[Measures].[X]measures and[Table].[Column]attribute or user hierarchies. A queried table keeps its columns alive —EVALUATE 'Sales', an Excel drillthrough orADDCOLUMNS('Sales', …)returns every one — but not its measures, which a table expression never returns. - Formats: the CSV an Eventhouse or KQL queryset exports (a UTF-8 BOM, quoted
multi-line fields), a JSON array of row objects (
az monitor log-analytics query, pandasto_json(orient="records")), a Kusto REST v1/v2 response, or a Log Analytics query API response. Any of them may be gzip-compressed (.csv.gz) or the only file in a zip archive; the format is sniffed from content, not the extension. Columns are matched by name; onlyEventTextis required. An unfiltered export works too: rows whoseOperationNameis notQueryEnd(storage-engine scans, discovers, metrics) are skipped silently. The Log AnalyticsPowerBIDatasetsWorkspacetable's column names (TimeGenerated,ArtifactName) are read as well. - Size limits. Kusto truncates a result at 500,000 rows or 64 MB by default (lift
it with
set notruncation;before the query) and marks it partial rather than failing — a truncated log silently undercounts. Filtering toQueryEndin the KQL, as above, keeps even months of a busy model far below that. - One model per log. An export can cover every model in the workspace. The rows
whose
ItemName(orItemId) is this model are used — the model is the.SemanticModelfolder's or.pbix/.pbit/.abffile's name, or--queries-item NAME|ID(queries_iteminripbi.toml) when the service name differs — and another model's queries are ignored, with a note: its'Date'[Year]says nothing about this model's. Rows that name no model are kept. A log that covers several models but not this one, or has no rows for an explicit--queries-item, counts none of its queries — the log is supplementary evidence, and a model nobody queried in the window is a real answer, not a failed run. A log of a single model under another name is used — most likely the same model, named differently locally. Either way the mismatch is astale_stateskip notice, so the run goes on but--strictfails it: in CI, a typo'd--queries-itemor a renamed model never passes silently. Prefer the model'sItemIdGUID as the key there (below) — it survives renames and folder names. - Notes: one
·note names the query count and the log's date window; a!note says how many queries other models' rows accounted for. The consumer hint under a verdict with findings changes to say that consumers outside the window stay invisible — a log is only as long as its retention, so a monthly report may not show up in a week of logs. - Output.
--jsoncounts the queried objects insummary.queried;ripbi deps --impactlists them in aQueriedsection (see deps.md). User names in the log never reach any output — only distinct counts. - An unreadable file, or one with no
EventTextcolumn, is exit2. AQueryEndrow with no text is a skip notice.
Pulling the log in CI
The Eventhouse's REST endpoint returns a Kusto v1 response, which --queries-from
reads as is. A service principal with read access to the workspace's monitoring KQL
database (the tenant must allow service principals to use Fabric APIs) is enough;
the query URI and database name are on the KQL database's page in Fabric. Pin the
model by its item id rather than its name — the GUID after /semanticmodels/ (or
/datasets/) in the model's Fabric URL — and gate with --strict, so a log that
stops matching fails the build instead of quietly counting nothing:
# ripbi.toml
queries_from = "query-log.json"
queries_item = "00000000-0000-0000-0000-000000000000" # the model's ItemId
az login --service-principal -u "$CLIENT_ID" -p "$CLIENT_SECRET" --tenant "$TENANT_ID"
TOKEN=$(az account get-access-token --resource "$KUSTO_URI" --query accessToken -o tsv)
KQL="SemanticModelLogs
| where Timestamp > ago(30d) and OperationName == 'QueryEnd' and ItemId == '$MODEL_ID'
| project Timestamp, OperationName, OperationDetailName, ItemId, ItemName,
ApplicationName, ApplicationContext, ExecutingUser, EventText"
jq -n --arg db "$KUSTO_DB" --arg csl "$KQL" '{db: $db, csl: $csl}' |
curl -sSf -X POST "$KUSTO_URI/v1/rest/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @- -o query-log.json
ripbi scan --strict # queries_from and queries_item come from ripbi.toml
With Log Analytics instead, az monitor log-analytics query --workspace <guid> --analytics-query "PowerBIDatasetsWorkspace | where OperationName == 'QueryEnd' …"
(project ArtifactId beside ArtifactName to match by id)
writes row objects ripbi reads the same way. Drop ExecutingUser from the projection
to keep user names out of the file entirely, at the cost of the distinct-user count.