Build export records from billable usage aggregates
What does this MR do and why?
Adds the read path that turns stored billable usage into the usage records an air-gapped usage export carries. It also extracts the instance-identity fields (Gitlab::BillingEvents::InstanceIdentity) out of the emission path, so the export envelope and emitted events derive them the same way. Nothing user-facing yet.
- Reads
billable_usage_daily_totals(added in !258358 (merged)), one row per day per usage type for the whole instance, about 730 rows a year regardless of namespace count. This follows database review (note). - Records have no namespace, because the export is per instance.
- Records are one per day per usage type.
timestampis midday UTC,quantityis a JSON number, andoperation_typeis omitted when nil. metadata.quantity_kindiscounterorsnapshot, derived from the event type.secrets_storedis the only snapshot.event_idis stable across re-exports, which is what CustomersDot dedupes on.- The export scopes
in_periodandordered_for_exportlive onUtilization::BillableUsage::DailyTotal. Summing happens in the totals refresh (!258358 (merged)). - The records are unchanged by the rework. On the same seed (a month, 4 namespaces including a nil one, 3 usage types, mixed
operation_type), the old summed query and the new totals read produce identical JSON (93 records, same SHA-256).
Reviewing this commit by commit
Each link opens that commit's diff.
Extract billing event instance identity: refactor of the emission path. No behavioural assertion changed in the client specs.Build export records from billable usage aggregates: scopes, record id and wire mapping.Expose the quantity kind on exported usage records: addsmetadata.quantity_kind.Cover the CE instance identity defaults in the EE suite: spec coverage.Read export records from the daily totals: the rework after database review.
Database
Read-only. No migration.
SELECT "billable_usage_daily_totals".* FROM "billable_usage_daily_totals" WHERE "billable_usage_daily_totals"."usage_date" BETWEEN '2025-10-07' AND '2026-10-06' ORDER BY "billable_usage_daily_totals"."usage_date" ASC NULLS FIRST, "billable_usage_daily_totals"."event_type" ASC NULLS FIRST, "billable_usage_daily_totals"."feature_qualified_name" ASC NULLS FIRST, "billable_usage_daily_totals"."operation_type" ASC NULLS FIRSTA whole year is read because every export covers the licence term.
Plan from Database Lab with a year of totals (730 rows): https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/61279/commands/166322
It reads the period through index_billable_usage_daily_totals_on_unique_tuple and sorts the 730 rows in memory, since the index orders NULLS LAST and the export orders NULLS FIRST.
How to set up and validate locally
Steps and expected output
Export records come from the daily totals
Seed per-namespace rows, refresh the totals, then build the records for the period. The output shown is from an actual run in a rolled-back transaction.
ns = Utilization::BillableUsage::DailyNamespaceAggregate
svc = Utilization::BillableUsage::Exports::UsageRecordsService
range = Date.new(2026, 8, 1)..Date.new(2026, 8, 31)
[[101, Date.new(2026, 8, 10), 40], [202, Date.new(2026, 8, 10), 60], [101, Date.new(2026, 8, 11), 5]].each do |root, date, qty|
ns.create!(usage_date: date, root_namespace_id: root, event_type: 'secrets_read',
feature_qualified_name: 'secrets_read', unit_of_measure: 'request', quantity: qty, events_count: qty)
end
Utilization::BillableUsage::RefreshDailyTotalsService.new.execute
records = svc.new(range).execute
records.size # => 2
records.first
# => {event_id: "f178638f-f516-5944-962a-28da0e229873", event_type: "secrets_read",
# timestamp: "2026-08-10T12:00:00Z", quantity: 100.0, unit_of_measure: "request",
# metadata: {feature_qualified_name: "secrets_read", schema_version: 1, events_count: 100,
# quantity_kind: "counter"}}
svc.new(Date.new(2026, 8, 10)..Date.new(2026, 8, 10)).execute.first[:event_id] == records.first[:event_id] # => trueThe extracted identity resolves from live instance state
Gitlab::BillingEvents::InstanceIdentity.to_h
# => {instance_id: "865a4e5c-...", unique_instance_id: "df73e4e6-...", host_name: "127.0.0.1",
# instance_version: "19.5.0", realm: "SM", deployment_type: "self-managed"}Each field should equal its source: Gitlab::GlobalAnonymousId.instance_id, Gitlab::GlobalAnonymousId.instance_uuid (unless a Cloud Connector token supplies a uid), Gitlab.config.gitlab.host, Gitlab.version_info.to_s, and CloudConnector.gitlab_realm mapped through REALM_MAP, with CloudConnector.deployment_type.
References
- Issue: https://gitlab.com/gitlab-org/customers-gitlab-com/-/work_items/18552
- Epic: https://gitlab.com/groups/gitlab-org/-/epics/23205
- Aggregate table and model: !248498 (merged). Local write path: !249324 (merged) (merged).
- Export stack, in merge order:
- !257174 (merged) - seat link daily series and the coverage block (merged)
- !258349 (merged) - partition and rename the per-namespace usage table (merged)
- !258358 (merged) - instance-wide daily totals and their worker (merged)
- !256937 (merged) (this MR) - export records: scopes, record id, wire mapping
- !256910 (merged) - export history table (merged)
- !257207 (merged) - period, checksum, payload envelope
- !257491 (merged) - admin endpoint,
billable_usage_exportflag, export history write (Draft)
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.