ADR — Tighten Analytics Contract Semantics
Status
Proposed
Date
2026-03-06
Context
The Analytics domain has a strong overall shape:
- analytics is separated from CRUD/resource domains
- aggregate endpoints follow a
list/summary/rankings/exportpattern - collection endpoints use cursor pagination
- comparison-period metrics use a structured wrapper instead of flat
*_change_pctfields
That foundation is good. The main remaining problem is contract precision.
Several analytics docs currently leave room for conflicting interpretations around:
- what
granularityactually changes - whether filters narrow row membership or only recalculate metrics
- which field names are canonical across the analytics domain
There are also secondary concerns that affect long-term scale and consumer trust:
- synchronous full-result exports for potentially large analytics datasets
- analytics rows embedding full CRUD objects instead of stable lightweight projections
- overlapping vocabulary such as
interactionsvssession_count - inconsistent
usage_leveldefinitions across analytics pages - inconsistent
periodobject shapes across time-series vs aggregate endpoints
This ADR assumes the current docs describe the intended final API contract and proposes the changes needed to make that contract easier to trust, easier to implement consistently, and easier for clients to consume at scale.
Findings
Primary findings
1. granularity is not mathematically honest enough today
The docs describe granularity as required and state that it affects some aggregate metrics, but the documented formulas do not always change with the selected value.
Examples:
- App Utilization Detail
- App Utilization Users for One App
- Design Guidelines: Analytics Pattern: Granularity
This creates three problems:
- consumers cannot predict whether changing
granularitychanges a result - caches gain extra query-key cardinality for parameters that may be no-ops
- implementers can produce incompatible behavior while still thinking they follow the docs
2. Filtered analytics collections do not have strict row-membership semantics
App Utilization documents department_ids as scoping usage metrics, but also states that apps with zero scoped usage still appear in the list. That makes the endpoint behave like an inventory report rather than a filtered analytics collection.
This creates three problems:
- filters stop being reliable row selectors
- pagination becomes sparse and less useful on large tenants
- export output becomes harder to reason about because filtered rows may still be irrelevant to the requested slice
3. Shared analytics vocabulary is inconsistent
The analytics docs currently mix multiple representations for the same concepts:
period.start/period.endin activity responsesperiod.start_date/period.end_datein aggregate responsesinteractionsin rankings copy vssession_countelsewhere- multiple implied definitions of
usage_level
Examples:
This creates three problems:
- shared client utilities need endpoint-specific adapters
- analytics feels less authoritative because the same concept is described differently across pages
- naming drift in docs can turn into naming drift in implementations and generated clients
Secondary findings
4. Export strategy is acceptable for small tenants but weak for large analytics workloads
App Utilization Export returns the full filtered dataset synchronously as CSV with no documented async mode, export threshold, or result-size cap.
That is simple, but it is not a durable pattern for very large tenants, wide time ranges, or expensive filtered joins.
5. Analytics rows are too tightly coupled to CRUD DTOs
App Utilization returns a full App object in every row even though the Analytics Overview says the analytics domain owns the numbers and CRUD domains own the resource definitions.
This is workable, but it increases payload size and causes analytics contracts to churn whenever CRUD DTOs expand.
6. Deprecated AI-specific analytics remain prominent in the main domain docs
Analytics Overview still gives first-class space to the deprecated AI family while also documenting the generic utilization family as its replacement.
That is acceptable during migration, but it weakens the clarity of the long-term contract and encourages new consumers to learn two overlapping ways to ask similar questions.
Decision
We will tighten the analytics contract in three primary ways.
Decision 1. granularity will only exist where it materially changes the response
We will split analytics metrics into two categories:
- whole-period metrics
- bucket-dependent metrics
Whole-period metrics
These metrics are totals or distinct counts across the requested period and do not change with aggregation bucket size:
unique_usersactive_time_mssession_countapp_countuser_count
Endpoints that return only whole-period metrics will not require granularity.
Bucket-dependent metrics
These metrics do change based on the aggregation unit and may require granularity:
- time-series endpoints
- rankings or averages whose denominator is bucket-based
- any metric explicitly computed per day, per week, per month, or per quarter
If a metric changes with bucket size, its name and formula must make that explicit.
Examples:
avg_active_time_per_bucket_msavg_session_count_per_bucket
If product wants a true per-day value regardless of selected bucket size, then the metric must be documented and computed as per-day across all endpoints and granularity must not be described as affecting it.
Contract rule
granularity is required only when changing it changes the value or structure of the response.
Decision 2. Filtered analytics collections will return only matching rows by default
Analytics list endpoints will use strict filter semantics.
Default rule
Query filters constrain row membership, not only metric calculation.
If a request applies filters such as:
department_idscategory_idsvendor_idsis_approvedfilter=discoveredfilter=dropped_off
then only rows that match that filtered slice may appear in data.
Rows with zero qualifying activity or zero qualifying metric value for the filtered period do not appear by default.
Explicit exception
If product needs an inventory-style view that includes zero rows, that must be expressed explicitly with a separate mode or endpoint, for example:
view=inventoryinclude_zero_rows=true
That mode is opt-in. It is not the default behavior of analytics list endpoints.
Contract rule
total_count, pagination, and export row membership always reflect the same filtered row set.
Decision 3. The analytics domain will adopt one shared vocabulary
We will standardize the following cross-endpoint contract terms.
Period object
All analytics responses that include a period object will use:
{
"period": {
"start_date": "2026-02-01",
"end_date": "2026-02-28",
"compare_start_date": null,
"compare_end_date": null
}
}
This applies to both aggregate and time-series analytics responses.
Canonical metric names
The contract will use one canonical field name per concept:
session_count, notinteractionsactive_time_msunique_userschange_pctcompare_value
UI copy may still render labels like "Interactions", but that is a presentation concern, not a contract concern.
Shared classification definitions
Enums and server-side classifications used across analytics must have one domain-wide meaning.
This applies in particular to:
usage_levelrisk_level
If usage_level is exposed in multiple analytics endpoints, it must use the same formula and thresholds everywhere unless a different field name is introduced for a different classification.
Contract rule
One concept gets one field name, one shape, and one definition across the entire analytics domain.
Deferred recommendations
The following changes are recommended but are not part of the primary contract decision in this ADR.
1. Introduce an async export pattern for large analytics datasets
Recommended future shape:
- synchronous CSV export for small result sets
- async export job for large result sets
- explicit server-side threshold or documented export cap
2. Prefer lightweight refs in analytics rows over full CRUD objects
Recommended future direction:
- analytics list rows embed stable lightweight refs such as
AppRef - full resource expansion remains the responsibility of CRUD detail endpoints
This keeps analytics focused on metrics and reduces payload churn.
Alternatives considered
Alternative A. Keep granularity required on all analytics endpoints for consistency
Rejected.
This improves superficial uniformity but keeps a misleading parameter on endpoints where it does not materially change results.
Alternative B. Let filters change metric values but not row membership
Rejected as the default.
This can be useful for inventory reporting, but it is a poor default for analytics collections because it makes filtered pages sparse and less predictable.
Alternative C. Allow each analytics family to define its own vocabulary
Rejected.
This preserves local flexibility but makes shared analytics tooling and consumer understanding worse over time.
Alternative D. Solve vocabulary differences only in frontend adapters
Rejected.
That hides contract inconsistency instead of fixing it and spreads translation logic across every client.
Consequences
Positive consequences
- the analytics contract becomes easier to trust because metric names match their formulas
- clients can reason about filters, pagination, and export membership consistently
- cache keys and SDK surfaces become smaller and more stable
- cross-endpoint analytics helpers become practical because shared structures are truly shared
- future analytics endpoints can extend the domain without inventing new local conventions
Negative consequences
- some existing docs and examples will need coordinated updates
- some endpoints may need query-parameter changes or field renames
- clients that already rely on loose filter behavior or inconsistent names may need migration work
Neutral consequences
- UI-facing labels may still use business terminology that differs from field names, as long as the contract stays canonical underneath
Migration plan
- Update Design Guidelines first so the source rules are authoritative.
- Normalize all analytics pages to those rules:
- Publish a short migration note for any renamed fields or removed parameters.
- Add a docs review checklist for new analytics pages:
- does
granularitymaterially change the result? - do filters narrow row membership?
- does the page use canonical period and metric names?
- does export match list row membership and sort order?
- does
Required doc changes
This section translates the decisions in this ADR into the concrete documentation changes required across the current analytics docs.
Decision 1. granularity must only appear where it changes the response
Update the shared rule
- Update Design Guidelines so it no longer says
granularityis required on all analytics endpoints. - Replace that rule with the contract rule from this ADR:
granularityis required only when changing it changes the value or structure of the response.
Update affected endpoint docs
- App Activity
- Decide whether this endpoint is truly daily-only or bucketed by
granularity. - If it is daily-only, remove
granularityfrom parameters and examples. - If it is bucketed, update the response examples and metric descriptions so the output matches the requested bucket size.
- Decide whether this endpoint is truly daily-only or bucketed by
- App Utilization Detail
- Either remove
granularity, or rename/redefineavg_time_ms_per_dayso the metric actually changes withdaily/weekly/monthly/quarterly. - Update the DB aggregation formulas to match the chosen behavior.
- Either remove
- App Utilization Users for One App
- Apply the same change as App Utilization Detail for
avg_time_ms_per_day. - Update sorting semantics if the metric name changes.
- Apply the same change as App Utilization Detail for
- App Utilization
- Apply the same change for row-level
avg_time_ms_per_day. - Update examples, notes, and DB aggregation formulas.
- Apply the same change for row-level
- App Utilization Export
- Keep export headers aligned with the JSON list contract if the metric name changes.
- App Utilization Summary
- Remove
granularityif the endpoint remains whole-period metrics only.
- Remove
- App Utilization by User
- Remove
granularityif the endpoint remains whole-period metrics only.
- Remove
- App Utilization by Department
- Remove
granularityif the endpoint remains whole-period metrics only.
- Remove
Decision 2. Filters must narrow row membership by default
Update the shared rule
- Add a row-membership rule to Design Guidelines:
- filters constrain which rows appear in
data total_countreflects filtered visible rows- exports use the exact same filtered row set as the JSON list
- filters constrain which rows appear in
Update affected endpoint docs
- App Utilization
- Remove the current note that apps with zero scoped usage still appear when
department_idsis provided. - Replace it with the default analytics rule: only matching rows appear unless an explicit inventory-style mode is documented.
- Clarify that
total_countreflects the filtered visible row set.
- Remove the current note that apps with zero scoped usage still appear when
- App Utilization Export
- Make explicit that export row membership matches the JSON list exactly after filtering.
- App Utilization by User
- Add the same row-membership clarification for filtered list semantics.
- App Utilization by Department
- Add the same row-membership clarification for filtered list semantics.
- App Utilization Users for One App
- Clarify that
department_idsnarrows returned users rather than keeping zero-value rows.
- Clarify that
Decision 3. Shared analytics vocabulary must be standardized
Update the shared rule
- Update Design Guidelines so the
periodobject shape is canonical across all analytics responses. - Add a canonical analytics vocabulary note to Design Guidelines:
session_countis the contract term, notinteractionsstart_date/end_dateare the canonical period field namesusage_levelmust use one domain-wide formula and threshold set
Update affected endpoint docs
- Analytics Overview
- Update the shared
ActivityTimeSeriesexample to useperiod.start_dateandperiod.end_date. - Replace descriptive uses of "interactions" where the actual contract metric is
session_count.
- Update the shared
- App Activity
- Update response examples from
period.start/period.endtoperiod.start_date/period.end_date.
- Update response examples from
- User Activity
- Update response examples from
period.start/period.endtoperiod.start_date/period.end_date.
- Update response examples from
- Device Activity
- Update response examples from
period.start/period.endtoperiod.start_date/period.end_date.
- Update response examples from
- App Utilization Rankings
- Replace "interactions" as the contract concept with
session_countin descriptions and ranking labels.
- Replace "interactions" as the contract concept with
- App Utilization Detail
- Keep
session_countas the contract term and align related wording with rankings and overview pages.
- Keep
- App Utilization
- Keep
session_countas the canonical metric name in examples, notes, and export alignment.
- Keep
- AI Apps Overview
- Align any shared
usage_leveldescription to the single domain-wide formula and threshold set.
- Align any shared
- App Utilization
- Align the documented
usage_levelformula and thresholds to the same shared definition.
- Align the documented
Checklist
Use this checklist when applying the ADR decisions to the docs.
Shared rules
- Update Design Guidelines to make
granularityconditional rather than universal. - Add a shared analytics row-membership rule to Design Guidelines.
- Standardize the shared
periodobject shape in Design Guidelines. - Add a canonical analytics vocabulary note to Design Guidelines.
granularity
- Decide whether App Activity is daily-only or bucketed by
granularity. - Remove or redefine
granularityon App Utilization Detail. - Remove or redefine
granularityon App Utilization Users for One App. - Remove or redefine
granularityon App Utilization. - Remove
granularityfrom App Utilization Summary if it remains whole-period only. - Remove
granularityfrom App Utilization by User if it remains whole-period only. - Remove
granularityfrom App Utilization by Department if it remains whole-period only. - Keep App Utilization Export aligned with the final JSON contract.
Filter semantics
- Update App Utilization so filters narrow row membership by default.
- Update App Utilization Export so export row membership matches the filtered JSON list.
- Update App Utilization by User to state the same list-filter rule.
- Update App Utilization by Department to state the same list-filter rule.
- Update App Utilization Users for One App to state the same list-filter rule.
Vocabulary
- Update Analytics Overview to use the canonical
periodshape. - Update App Activity to use the canonical
periodshape. - Update User Activity to use the canonical
periodshape. - Update Device Activity to use the canonical
periodshape. - Replace "interactions" with
session_countas the contract term in App Utilization Rankings. - Align
session_countwording across App Utilization Detail, App Utilization, and Analytics Overview. - Choose one
usage_levelformula and threshold set and apply it consistently in App Utilization and AI Apps Overview.
Validation
- Rebuild the docs site and verify there are no broken links or anchors.
- Re-read examples to confirm field names, formulas, and query params match the final chosen contract.