Event Measurements

Cross-source quantitative analysis through the measurements facet — extract multiple business measurements from events using the standard EAV pattern.

Event measurements bring quantitative business concepts — revenue, tax, expenses, hours, counts, scores — into the nexus compressed schema, making them queryable across sources without source-specific knowledge.

Measurements vs Metrics

These terms are often conflated (Cube and other semantic layers use them interchangeably), but they are distinct concepts with roots in formal measurement theory.

  • Measurement: A specific quantitative observation tied to a specific event. "This invoice's revenue was $100." Measurements live in the data. They are nexus's responsibility.
  • Metric: An aggregation or arithmetic operation over measurements. "Total revenue this quarter was $50,000." or "Average invoice revenue was $200." Metrics are computed at query time. They are the semantic layer's responsibility (or the LLM's, or the analyst's).

Nexus outputs measurements. Whatever sits on top — Cube, an LLM, a dashboard — computes metrics from those measurements.

Why "Measurement"?

The terminology is deliberate and grounded in measurement science (metrology).

A measurement is an observation, not ground truth. In metrology — the formal science of measurement standardized by NIST and the BIPM — every measurement has inherent uncertainty. An invoice amount of $100 is really "$100 as observed by FreshBooks at this point in time, subject to future adjustments." Credit notes, refunds, currency rounding, and timing differences between systems all introduce uncertainty. The measurement captures what was observed at extraction time.

A metric is a derived quantity. Metrology distinguishes between direct measurements (observations from a source) and derived quantities (computed from measurements via an equation). SUM(revenue measurements) is a derived quantity. The uncertainty from individual measurements propagates into the aggregate — if one invoice is later adjusted, the total changes too.

Measurements carry dimensional metadata. Dimensional analysis — the branch of mathematics concerned with units — tells us that you can only add quantities with the same dimensions. Revenue in USD + Revenue in CAD requires conversion. Revenue + Hours is meaningless. The value_unit column carries this dimensional metadata, making each measurement self-describing and preventing invalid aggregations.

Measurements exist on specific scales. Stevens' Scales of Measurement (1946) classify data by what operations are valid:

Scale Property Valid Operations Example Measurements
Ratio True zero, meaningful distances SUM, AVG, ratios revenue, hours, weight
Interval No true zero, meaningful distances AVG, differences temperature_celsius
Ordinal Ordered, no meaningful distances Rank, median satisfaction_score
Nominal Categories only Count, mode (not measurements — these are traits)

Most business measurements (revenue, hours, amounts) are ratio scale — summing them is valid. But the distinction matters when measurements include scores or ratings where summation is technically invalid.

This formal grounding ensures the terminology is precise: measurements are observations with uncertainty, units, and scale properties. Metrics are derived from measurements. Nexus handles the former; the query layer handles the latter.


The Problem

A single business record often carries multiple quantitative dimensions. A Stripe invoice has total, subtotal, tax, amount_paid, amount_remaining — five distinct financial measurements from one record. A FreshBooks expense has amount and tax_amount_1. An Upwork transaction has amount but the meaning depends on transaction_type (earnings vs service fees).

Without measurements, answering "What was my revenue for the past 6 years?" requires breaking out of the nexus compressed schema and dropping back to source-specific tables -- joining individual event models, knowing which source/event_name combinations constitute "revenue," what transformations are needed (Stripe amounts are in cents), and how to avoid double-counting. It's brittle, non-portable across clients, and impossible for an LLM to infer without deep domain knowledge.

Measurements close this gap.


The Solution: Measurements as a Facet

Measurements follow the same faceted extraction pattern as identifiers, traits, states, and attribution. The intermediate EAV table stores:

  • The entity is the event (event_id)
  • The attribute is the measurement name (measurement_name)
  • The value is the quantitative payload (value + value_unit)

A single event can produce multiple measurement rows, each from a different source column with a different semantic meaning:

Source Record: Stripe Invoice #in_abc123 (paid)
  → measurement: measurement_name = 'revenue',   value = 100.00, value_unit = 'usd'
  → measurement: measurement_name = 'subtotal',  value = 92.00,  value_unit = 'usd'
  → measurement: measurement_name = 'tax',       value = 8.00,   value_unit = 'usd'

Source Record: FreshBooks Expense #456
  → measurement: measurement_name = 'expense',   value = -50.00, value_unit = 'cad'
  → measurement: measurement_name = 'tax_paid',  value = -6.50,  value_unit = 'cad'

Source Record: Upwork Transaction #789 (Fixed-price)
  → measurement: measurement_name = 'revenue',   value = 500.00, value_unit = 'usd'

Events themselves carry no numeric value column -- quantitative data lives entirely in measurements. Measurements capture all quantitative dimensions of an event, each with its own name, value, and unit.


Schema

Core Table: nexus_event_measurements

The analyst-facing table. One row per event, one column per measurement type. Measurement columns are discovered dynamically at compile time — new measurement types from new sources appear automatically on the next dbt build.

Column Type Required Description
event_id STRING FK to the event
occurred_at TIMESTAMP When (denormalized from event)
source STRING Source system
{measurement_name} NUMERIC One column per measurement (e.g., revenue, tax, expense). NULL if not applicable.

The event_id linkage connects measurements to the full nexus entity graph through Events → Participants → Entities, enabling queries like "Revenue by customer" without any source-specific joins.

Units are available in nexus_event_measurements_metadata (one row per measurement_name/source combination with unit, count, and value statistics).

Intermediate Table: nexus_event_measurements_unioned

The EAV canonical form. One row per measurement per event. This is what source models feed into and what the core table pivots from.

Column Type Required Description
event_measurement_id STRING Unique ID (evt_msr_ prefix)
event_id STRING FK to the event this measurement came from
measurement_name STRING What was measured (the EAV discriminator)
value NUMERIC The observed quantitative value
value_unit STRING Unit of measurement (currency, hours, count)
occurred_at TIMESTAMP When (denormalized from event)
source STRING Source system

You should not need to query this table directly — use the core table instead.


Extraction Pattern

Measurements follow the standard four-layer architecture described in Recommended Source Model Structure.

Directory Structure

models/sources/{source_name}/
├── intermediate/
│   ├── {source}_invoice_events.sql             ← existing
│   ├── {source}_invoice_measurements.sql       ← NEW
│   ├── {source}_expense_events.sql             ← existing
│   ├── {source}_expense_measurements.sql       ← NEW
│   ├── {source}_person_identifiers.sql         ← existing
│   └── {source}_group_identifiers.sql          ← existing
├── {source}_events.sql                         ← existing (unions events)
├── {source}_entity_identifiers.sql             ← existing (unions identifiers)
├── {source}_event_measurements.sql             ← NEW (unions measurements)

Intermediate Measurement Models

Each intermediate measurement model:

  1. Reads from intermediate event models for event_id linkage and timing
  2. Joins to normalized tables for additional financial columns not on the event
  3. Unpivots multiple quantitative columns into separate measurement rows

This mirrors how identifier models read from events and extract multiple identifier rows per event.

Example: stripe_invoice_measurements.sql

{{ config(
    materialized='table',
    tags=['stripe', 'intermediate', 'measurements']
) }}

-- Unpivot financial dimensions from Stripe invoices into measurement rows
-- One invoice paid event → multiple measurements (revenue, subtotal, tax)

WITH invoice_paid_events AS (
    SELECT event_id, invoice_id, occurred_at, source
    FROM {{ ref('stripe_invoice_historical_events') }}
    WHERE event_name = 'invoice paid'
),

invoices AS (
    SELECT * FROM {{ ref('stripe_invoices') }}
),

-- Join events to normalized invoices for full financial detail
event_financials AS (
    SELECT
        e.event_id,
        e.occurred_at,
        e.source,
        CAST(i.total AS NUMERIC) / 100 as total,
        CAST(i.subtotal AS NUMERIC) / 100 as subtotal,
        CAST(i.total - i.subtotal AS NUMERIC) / 100 as tax,
        i.currency
    FROM invoice_paid_events e
    JOIN invoices i ON e.invoice_id = i.id
),

revenue AS (
    SELECT
        {{ nexus.create_nexus_id('event_measurement', ['event_id', "'revenue'"]) }}
            as event_measurement_id,
        event_id,
        'revenue' as measurement_name,
        total as value,
        currency as value_unit,
        occurred_at,
        source
    FROM event_financials
),

subtotal_measurement AS (
    SELECT
        {{ nexus.create_nexus_id('event_measurement', ['event_id', "'subtotal'"]) }}
            as event_measurement_id,
        event_id,
        'subtotal' as measurement_name,
        subtotal as value,
        currency as value_unit,
        occurred_at,
        source
    FROM event_financials
),

tax_measurement AS (
    SELECT
        {{ nexus.create_nexus_id('event_measurement', ['event_id', "'tax'"]) }}
            as event_measurement_id,
        event_id,
        'tax' as measurement_name,
        tax as value,
        currency as value_unit,
        occurred_at,
        source
    FROM event_financials
    WHERE tax > 0
)

SELECT * FROM revenue
UNION ALL
SELECT * FROM subtotal_measurement
UNION ALL
SELECT * FROM tax_measurement

Example: freshbooks_expense_measurements.sql

{{ config(
    materialized='table',
    tags=['freshbooks', 'intermediate', 'measurements']
) }}

-- Unpivot financial dimensions from FreshBooks expenses into measurement rows
-- One expense event → expense amount + tax paid

WITH expense_events AS (
    SELECT * FROM {{ ref('freshbooks_expense_events') }}
),

expenses AS (
    SELECT * FROM {{ ref('freshbooks_expenses') }}
),

event_financials AS (
    SELECT
        e.event_id,
        e.occurred_at,
        e.source,
        exp.amount,
        exp.tax_amount_1,
        exp.amount_code
    FROM expense_events e
    JOIN expenses exp ON CAST(exp.id AS STRING) = e.invoice_id
),

expense_amount AS (
    SELECT
        {{ nexus.create_nexus_id('event_measurement', ['event_id', "'expense'"]) }}
            as event_measurement_id,
        event_id,
        'expense' as measurement_name,
        -1 * amount as value,
        amount_code as value_unit,
        occurred_at,
        source
    FROM event_financials
),

tax_paid_measurement AS (
    SELECT
        {{ nexus.create_nexus_id('event_measurement', ['event_id', "'tax_paid'"]) }}
            as event_measurement_id,
        event_id,
        'tax_paid' as measurement_name,
        -1 * tax_amount_1 as value,
        amount_code as value_unit,
        occurred_at,
        source
    FROM event_financials
    WHERE tax_amount_1 IS NOT NULL AND tax_amount_1 > 0
)

SELECT * FROM expense_amount
UNION ALL
SELECT * FROM tax_paid_measurement

Source-Level Union: stripe_event_measurements.sql

{{ config(
    materialized='table',
    tags=['nexus', 'measurements', 'stripe']
) }}

-- Union all Stripe measurement types
{{ dbt_utils.union_relations(
    relations=[
        ref('stripe_invoice_measurements')
    ]
) }}

ORDER BY occurred_at DESC

Querying Measurements

The core nexus_event_measurements table is pivoted — each measurement type is a column. No WHERE measurement_name = ... needed.

Simple Aggregation

-- "What was my revenue for the past 6 years?"
SELECT
    EXTRACT(YEAR FROM occurred_at) AS year,
    SUM(revenue) AS total_revenue
FROM nexus_event_measurements
GROUP BY 1
ORDER BY 1

Multi-Measurement Comparison

-- "Show me revenue vs expenses by quarter"
SELECT
    DATE_TRUNC(occurred_at, QUARTER) AS quarter,
    SUM(revenue) AS total_revenue,
    SUM(expense) AS total_expenses
FROM nexus_event_measurements
GROUP BY 1
ORDER BY 1

Derived Metrics

-- "What's my profit?" (expenses are already negative)
SELECT
    EXTRACT(YEAR FROM occurred_at) AS year,
    SUM(revenue) + SUM(expense) AS profit
FROM nexus_event_measurements
GROUP BY 1
ORDER BY 1

Entity-Attributed Measurements

-- "Revenue by customer"
SELECT
    nexus_entities.name AS customer,
    SUM(nexus_event_measurements.revenue) AS total_revenue
FROM nexus_event_measurements
JOIN nexus_entity_participants
    ON nexus_event_measurements.event_id = nexus_entity_participants.event_id
JOIN nexus_entities
    ON nexus_entity_participants.entity_id = nexus_entities.entity_id
WHERE nexus_entities.entity_type = 'group'
GROUP BY 1
ORDER BY 2 DESC

Invoice Detail

-- "Give me a table of my invoices with revenue and tax"
SELECT
    event_id,
    occurred_at,
    source,
    revenue,
    tax
FROM nexus_event_measurements
WHERE revenue IS NOT NULL
ORDER BY occurred_at DESC

Discoverability

-- "What measurements are available and in what units?"
SELECT * FROM nexus_event_measurements_metadata

Measurements Are Not Just Financial

While revenue, tax, and expenses are the motivating examples, measurements are general-purpose. Any quantitative dimension of an event can be a measurement:

Domain Example Measurements
Finance revenue, tax, subtotal, expense, platform_fee
Time hours_worked, resolution_time, time_to_close
Usage api_calls, data_transferred_gb, active_users
Sales deal_amount, probability, discount_amount
Healthcare copay, insurance_reimbursement, patient_payment
E-commerce order_total, shipping_cost, refund_amount

The schema is the same regardless of domain. The measurement_name discriminator and value_unit carry the semantics.


Configuration

Configure measurement sources in dbt_project.yml:

vars:
  nexus:
    sources:
      stripe:
        enabled: true
        events: true
        measurements: true
        entities: ['person', 'group']
      freshbooks:
        enabled: true
        events: true
        measurements: true
        entities: ['person', 'group']
      upwork:
        enabled: true
        events: true
        measurements: true
        entities: ['person', 'group']

The nexus package automatically unions all enabled source measurements into nexus_event_measurements.


Metadata

The nexus_event_measurements_metadata table catalogs available measurements:

SELECT * FROM nexus_event_measurements_metadata

This metadata table is automatically maintained and serves as the LLM-queryable catalog of what measurements exist, what units they use, and which sources contribute them.


Measurements and the Semantic Layer

Measurements are almost a semantic layer — but intentionally not one.

A traditional semantic layer (Cube, LookML, dbt Metrics) defines what can be measured, how to aggregate it, what dimensions to group by, and provides a query API. Nexus measurements handle the hardest part of that problem — cross-source extraction and labeling — and leave the rest to whatever query layer sits on top.

Responsibility Nexus Measurements Semantic Layer
What IS revenue across 5 sources?
Unit tagging and embedded semantics
Entity linkage through event graph
Discoverability via metadata tables
Canonical metric definitions ✅ (metadata)
Aggregation function (SUM vs AVG)
Dimension definitions (group-by axes)
Time intelligence (YoY, MTD, rolling)
Derived metrics (profit = rev + exp)
Query API / SQL generation

This boundary is intentional. Nexus is a transformation package — it should output clean, standardized, context-rich data and handle as much of that work as possible. Nexus does provide canonical metric definitions as a metadata table — the recipes for computing business numbers — but the actual aggregation, time intelligence, and SQL generation remain concerns of whatever query layer sits on top: an LLM, Cube, a BI tool, or a human writing SQL.

The cleaner the data substrate, the less any semantic layer has to do, and the more interchangeable it becomes. This is the commoditized complement strategy: nexus makes the data layer so clean that the query layer becomes a lightweight, swappable commodity.


Relationship to Other Facets

Measurements complete the nexus facet set:

erDiagram
    EVENTS ||--o{ IDENTIFIERS : "who was involved"
    EVENTS ||--o{ TRAITS : "what do we know about them"
    EVENTS ||--o{ MEASUREMENTS : "what was the quantitative impact"
    EVENTS ||--o{ STATES : "what state changed"
    EVENTS ||--o{ ATTRIBUTION : "what caused this"
    EVENTS ||--o{ RELATIONSHIPS : "what connections exist"

All facets link to events via event_id. Events link to entities via participants. This means every facet is automatically attributable to entities without source-specific joins.