Skip to main content

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 / export pattern
  • collection endpoints use cursor pagination
  • comparison-period metrics use a structured wrapper instead of flat *_change_pct fields

That foundation is good. The main remaining problem is contract precision.

Several analytics docs currently leave room for conflicting interpretations around:

  • what granularity actually 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 interactions vs session_count
  • inconsistent usage_level definitions across analytics pages
  • inconsistent period object 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:

This creates three problems:

  • consumers cannot predict whether changing granularity changes 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.end in activity responses
  • period.start_date / period.end_date in aggregate responses
  • interactions in rankings copy vs session_count elsewhere
  • 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_users
  • active_time_ms
  • session_count
  • app_count
  • user_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_ms
  • avg_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_ids
  • category_ids
  • vendor_ids
  • is_approved
  • filter=discovered
  • filter=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=inventory
  • include_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, not interactions
  • active_time_ms
  • unique_users
  • change_pct
  • compare_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_level
  • risk_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

  1. Update Design Guidelines first so the source rules are authoritative.
  2. Normalize all analytics pages to those rules:
  3. Publish a short migration note for any renamed fields or removed parameters.
  4. Add a docs review checklist for new analytics pages:
    • does granularity materially 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?

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 granularity is required on all analytics endpoints.
  • Replace that rule with the contract rule from this ADR:
    • granularity is 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 granularity from parameters and examples.
    • If it is bucketed, update the response examples and metric descriptions so the output matches the requested bucket size.
  • App Utilization Detail
    • Either remove granularity, or rename/redefine avg_time_ms_per_day so the metric actually changes with daily / weekly / monthly / quarterly.
    • Update the DB aggregation formulas to match the chosen behavior.
  • 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.
  • App Utilization
    • Apply the same change for row-level avg_time_ms_per_day.
    • Update examples, notes, and DB aggregation formulas.
  • App Utilization Export
    • Keep export headers aligned with the JSON list contract if the metric name changes.
  • App Utilization Summary
    • Remove granularity if the endpoint remains whole-period metrics only.
  • App Utilization by User
    • Remove granularity if the endpoint remains whole-period metrics only.
  • App Utilization by Department
    • Remove granularity if the endpoint remains whole-period metrics only.

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_count reflects filtered visible rows
    • exports use the exact same filtered row set as the JSON list

Update affected endpoint docs

  • App Utilization
    • Remove the current note that apps with zero scoped usage still appear when department_ids is provided.
    • Replace it with the default analytics rule: only matching rows appear unless an explicit inventory-style mode is documented.
    • Clarify that total_count reflects the filtered visible row set.
  • 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_ids narrows returned users rather than keeping zero-value rows.

Decision 3. Shared analytics vocabulary must be standardized

Update the shared rule

  • Update Design Guidelines so the period object shape is canonical across all analytics responses.
  • Add a canonical analytics vocabulary note to Design Guidelines:
    • session_count is the contract term, not interactions
    • start_date / end_date are the canonical period field names
    • usage_level must use one domain-wide formula and threshold set

Update affected endpoint docs

  • Analytics Overview
    • Update the shared ActivityTimeSeries example to use period.start_date and period.end_date.
    • Replace descriptive uses of "interactions" where the actual contract metric is session_count.
  • App Activity
    • Update response examples from period.start / period.end to period.start_date / period.end_date.
  • User Activity
    • Update response examples from period.start / period.end to period.start_date / period.end_date.
  • Device Activity
    • Update response examples from period.start / period.end to period.start_date / period.end_date.
  • App Utilization Rankings
    • Replace "interactions" as the contract concept with session_count in descriptions and ranking labels.
  • App Utilization Detail
    • Keep session_count as the contract term and align related wording with rankings and overview pages.
  • App Utilization
    • Keep session_count as the canonical metric name in examples, notes, and export alignment.
  • AI Apps Overview
    • Align any shared usage_level description to the single domain-wide formula and threshold set.
  • App Utilization
    • Align the documented usage_level formula and thresholds to the same shared definition.

Checklist

Use this checklist when applying the ADR decisions to the docs.

Shared rules

granularity

Filter semantics

Vocabulary

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.

References