Build a Product Usage Dashboard on a Fabric Lakehouse
An end-to-end Microsoft Fabric tutorial: land raw usage events in a Lakehouse, clean and shape them with a notebook into a star schema, connect Power BI in Direct Lake mode, and build a report — no Import refresh involved.
The sales analysis tutorial builds a model the traditional way: Power Query connects straight to a file, and Power BI imports the result. This tutorial builds the same kind of star schema a different way — landing raw data in a Fabric Lakehouse, shaping it with a notebook, and connecting Power BI in Direct Lake mode, which reads the Lakehouse's Delta tables directly with no import step at all.
Raw Usage Events (messy, one flat table, CSV)
|
| Land in Lakehouse Files
|
Bronze: raw Delta table (as landed)
|
| Notebook: clean, dedupe, split into a star schema
|
Gold: FactEvents, DimUser, DimFeature, DimDate
|
| Power BI semantic model (Direct Lake — no import)
|
Report (active users, feature adoption, trend)This needs a Fabric-enabled workspace (a free trial capacity is enough) — everything else is small enough to paste in directly, the same honest-about-messy-data approach as the sales tutorial.
What You're Building
A one-page usage report for a small SaaS product, answering four questions a product manager actually asks:
- How many people are actively using the product, and on which days?
- Which features are actually getting used?
- Are a handful of power users driving most of the activity?
- Is usage trending up or down over time?
+------------------------------------------------------------+
| Active Users | Total Events | Events/User | 7-Day AU |
+------------------------------------------------------------+
| Active Users Trend (line chart, by day) |
+------------------------------------------------+-----------+
| Feature Adoption % (bar chart) | Top 10 |
| | Users |
| | (table) |
+------------------------------------------------------------+Step 1: The Raw Data
A real product's event log arrives as a firehose of rows — one per action, no structure beyond "something happened." This one is deliberately small but has the same shape: repeated user and feature details on every row, mixed casing, and a few exact duplicate rows from a retried write upstream.
EventID,EventDate,UserEmail,FeatureName,EventType
1,2026-06-01,[email protected],Dashboard,view
2,2026-06-01,[email protected],Export,click
3,2026-06-01,[email protected],Export,click
4,2026-06-02,[email protected],Dashboard,view
5,2026-06-02,[email protected],Dashboard,view
5,2026-06-02,[email protected],Dashboard,view
6,2026-06-03,[email protected],Alerts,click
7,2026-06-03,[email protected],Dashboard,view
8,2026-06-04,[email protected],Alerts,click
9,2026-06-05,[email protected],Export,click
10,2026-06-05,[email protected],Export,click
11,2026-06-06,[email protected],Dashboard,view
12,2026-06-06,[email protected],Dashboard,viewNote row 5 appears twice, and UserEmail casing is inconsistent ([email protected] vs [email protected]) — both get fixed in the notebook step below, not by hand. Save this as usage_events.csv.
Step 2: Create the Lakehouse and Land the Raw File
In a Fabric-enabled workspace: New > Lakehouse, give it a name (e.g. UsageLakehouse). Once it opens, use the Files pane's upload button to drop usage_events.csv into a raw folder.
UsageLakehouse
Files/
raw/
usage_events.csv <- landed here, untouched
Tables/
(empty so far)In production this landing step would usually be a scheduled Data Factory pipeline instead of a manual upload — the notebook work below is identical either way.
Step 3: Clean the Raw Data with a Notebook
Attach a new notebook to the Lakehouse (Open notebook > New notebook, then select UsageLakehouse as the default Lakehouse). Read the raw file, fix the casing and duplicate rows, and land the result as a Delta table — this is the "bronze" layer described in Lakehouse.
from pyspark.sql.functions import trim, lower, to_date
raw = spark.read.format("csv").option("header", "true").load("Files/raw/usage_events.csv")
cleaned = (
raw
.withColumn("UserEmail", trim(lower("UserEmail")))
.withColumn("EventDate", to_date("EventDate"))
.dropDuplicates(["EventID"])
)
cleaned.write.format("delta").mode("overwrite").save("Tables/bronze_events")dropDuplicates(["EventID"]) removes the exact-duplicate row from earlier, and lower() fixes the inconsistent email casing before it ever becomes a join key — the same problem, and the same fix, as Trailing Whitespace or Case Differences causing a lookup to silently fail.
Step 4: Split Into a Star Schema
bronze_events is still one flat table. Build the dimension and fact tables from it, the same star schema shape as Star Schema describes — just built in PySpark instead of Power Query.
Flat table (what we have now):
EventID | EventDate | UserEmail | FeatureName | EventType
Star schema (what we want):
DimUser DimFeature DimDate
UserKey FeatureKey Date
UserEmail FeatureName
\ | /
\ | /
FactEvents
EventID, UserKey, FeatureKey,
Date, EventTypeBuild DimUser and DimFeature
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number
bronze = spark.read.format("delta").load("Tables/bronze_events")
dim_user = (
bronze.select("UserEmail").distinct()
.withColumn("UserKey", row_number().over(Window.orderBy("UserEmail")))
)
dim_user.write.format("delta").mode("overwrite").save("Tables/DimUser")
dim_feature = (
bronze.select("FeatureName").distinct()
.withColumn("FeatureKey", row_number().over(Window.orderBy("FeatureName")))
)
dim_feature.write.format("delta").mode("overwrite").save("Tables/DimFeature")row_number() over a defined ordering generates the surrogate key — the notebook equivalent of Table.AddIndexColumn in the sales tutorial's Power Query version.
Build DimDate
dim_date = spark.sql("""
SELECT explode(sequence(to_date('2026-06-01'), to_date('2026-06-30'), interval 1 day)) AS Date
""")
dim_date.write.format("delta").mode("overwrite").save("Tables/DimDate")A continuous date range, not just the dates that happen to appear in the events — the same reasoning as Date Tables, built with sequence() instead of DAX's CALENDAR().
Build FactEvents
fact_events = (
bronze
.join(dim_user, on="UserEmail", how="left")
.join(dim_feature, on="FeatureName", how="left")
.select("EventID", "UserKey", "FeatureKey", "EventDate", "EventType")
.withColumnRenamed("EventDate", "Date")
)
fact_events.write.format("delta").mode("overwrite").save("Tables/FactEvents")Refresh the Lakehouse's Tables pane and all four Delta tables should be visible and queryable through the SQL analytics endpoint immediately — no separate load step, the same point Lakehouse makes.
Step 5: Build the Semantic Model in Direct Lake
From the Lakehouse, New semantic model, and select all four tables. This is the key difference from the sales tutorial: there's no Get Data, no Refresh button, and no wait for an import to finish — the model reads Tables/ directly.
Import mode: Source -> copy into the model on refresh -> stale until refreshed
Direct Lake mode: OneLake Delta tables -> read directly -> always currentIn the semantic model's Model view, draw the relationships exactly as the star schema diagram shows: FactEvents[UserKey] to DimUser[UserKey], FactEvents[FeatureKey] to DimFeature[FeatureKey], FactEvents[Date] to DimDate[Date] — all one-to-many, dimension tables on the "one" side. Then mark DimDate as the model's date table (Table tools > Mark as Date Table) exactly as in a normal Import model. See Direct Lake Mode for how framing and transcoding work under the hood — worth reading before this model goes anywhere near production data.
Step 6: Write the Core Measures
DAX in a Direct Lake model is written exactly like DAX in an Import model — the storage mode doesn't change the language.
Active Users =
DISTINCTCOUNT(FactEvents[UserKey])
Total Events =
COUNTROWS(FactEvents)
Events Per User =
DIVIDE([Total Events], [Active Users])Feature Adoption %
Total Active Users =
CALCULATE(
[Active Users],
ALL(DimFeature)
)
Feature Adoption % =
DIVIDE([Active Users], [Total Active Users])ALL(DimFeature) removes whatever feature the current row is filtered to, so [Total Active Users] always means "everyone," not just users of the currently-filtered feature — the same percent-of-total shape as the DAX CALCULATE Modifiers Cheat Sheet. With DimFeature[FeatureName] on the rows of a table or bar chart, [Feature Adoption %] shows what share of all active users touched each feature.
7-Day Active Users
Active Users (7-Day) =
CALCULATE(
[Active Users],
DATESINPERIOD(DimDate[Date], MAX(DimDate[Date]), -7, DAY)
)A rolling window is a steadier signal than raw daily active users, which can look noisy day to day on a small user base. See Time Intelligence for more patterns like this one.
Step 7: Lay Out the Report
Page: "Usage Overview"
Row 1 (KPI cards):
[Active Users] [Total Events] [Events Per User] [Active Users (7-Day)]
Row 2 (line chart):
X-axis: DimDate[Date]
Y-axis: [Active Users]
Also plot: [Active Users (7-Day)] as a second line, to smooth the daily trend
Row 3 (left, bar chart): Row 3 (right, table):
X-axis: DimFeature[FeatureName] Columns: DimUser[UserEmail],
Y-axis: [Feature Adoption %] [Total Events]
Sorted by: [Total Events] descending
Slicer (top of page): DimDate[Date]See Charts and Tables for choosing and configuring these visual types — the same guidance applies regardless of storage mode.
Common Mistakes
Adding a Calculated Column Instead of Fixing It Upstream
Direct Lake doesn't support calculated columns or calculated tables directly — a column like a derived UserDomain needs to be added in the notebook (Step 3 or 4) before the table is written, not bolted on afterward in the semantic model. See Direct Lake Limitations for the full list of what doesn't carry over from Import.
Forgetting to Reframe After New Data Lands
Re-running the notebook to add a new day's events doesn't automatically update the report — Direct Lake needs to reframe to pick up the change. See Reframing Instead of Refresh for how that works and how to schedule it.
Building Every Table as One Big Notebook Cell
Splitting bronze, dimension, and fact table logic into separate cells (as done above) makes it possible to re-run just the piece that changed, and to actually see which step produced a wrong result — a single giant cell hides both.
Tutorial Checklist
- Raw data lands in Files before any transformation touches it.
- The star schema is built in Tables as separate Delta tables, with surrogate keys generated explicitly.
DimDateis a continuous date range, explicitly marked as the model's date table.- Relationships and the date table are set the same way they would be in an Import model — Direct Lake doesn't change this step.
- Any derived/calculated logic lives in the notebook, not as a calculated column on the semantic model.
Next Steps
This tutorial deliberately kept the pipeline manual. To go further with the same model:
- Data Factory — automate the Files upload and notebook run on a schedule instead of running them by hand.
- OneLake — what's actually storing these Delta tables underneath the Lakehouse.
- Row-Level Security — restricting each team to their own slice of usage data.
- Aggregations — pre-summarizing
FactEventsas event volume grows past what a single table comfortably handles.