Cost Attribution
A service definition’s costAttribution stanza declares how the service’s cloud
cost is split across product feature categories. make generate turns it into a
recording rule in a new per-service rule file in the gitlab-gprd Mimir tenant,
and the cost warehouse reads that metric to apportion the service’s bill.
The schema is defined and validated in
libsonnet/servicemetrics/service_definition.libsonnet,
the rules are built in
libsonnet/recording-rules/cost-attribution-usage.libsonnet
and written out by
mimir-rules-jsonnet/cost-attribution-usage.jsonnet.
The reasoning behind the models lives in the
cost attribution design document;
this page only covers how to declare one.
The metric
Section titled “The metric”Every declaring service records the same metric:
gitlab:feature_category:cost_attribution:usage:rate_5m{type="<service>", feature_category="<category>"}type and feature_category are its only labels. The value is an intensity
sampled every five minutes: ops/s for a counter, bytes for a gauge, a constant for
the direct and weighted models. The warehouse integrates it over a day and turns
each category’s share within the service into a share of the service’s cost, so
the unit cancels and a counter, a gauge and a constant all work on the same metric.
The rate_5m suffix denotes the recording cadence, not the kind of value.
Models
Section titled “Models”model | Meaning | Required | Optional |
|---|---|---|---|
direct | The whole service belongs to one feature category | featureCategory | note |
proportional | Split by a usage signal that carries a feature_category label | usage | note |
weighted | Split by fixed weights agreed with the service owner | weights, note | |
none | The recorded decision that this service has no model | note |
Any key not listed for a model is rejected, as is any other model. Leaving
costAttribution out altogether is different from none: absent means nobody has
decided yet and the warehouse reports the service as unattributed; none says the
same about the cost but records that it was looked at, which is why it needs a
note.
featureCategory, and every key of weights, must be a feature category from
stages.yml.
not_owned is never allowed. unknown is allowed in weights only, for the
remainder that should fold into unattributed.
Direct
Section titled “Direct”costAttribution: { model: 'direct', featureCategory: 'container_registry',},Produces one rule with expr: vector(1) and both labels.
Proportional
Section titled “Proportional”local rateMetric = metricsCatalog.rateMetric;
costAttribution: { model: 'proportional', usage: rateMetric( counter='gitlab_sql_duration_seconds_sum', selector={ db_config_name: { re: 'main(_replica)?' } }, ), note: ||| Client-side Rails query time as a proxy for storage and compute together. |||,},Produces one rule labelled with type only; feature_category comes from the
data. The expression is the usage metric aggregated by feature_category with the
tenant’s env="gprd" merged into the selector. Nothing else is added: in
particular there is no type="<service>" matcher, because the usage signal is
often emitted by another service (Rails emits gitlab_sql_duration_seconds_sum,
not patroni). The owner’s selector plus env is the whole selector.
How the expression is rendered depends on the usage form, see the cost of the query below. Either way the generated file shows what Mimir evaluates.
Weighted
Section titled “Weighted”costAttribution: { model: 'weighted', weights: { source_code_management: 0.7, continuous_integration: 0.3, }, note: ||| 2026-09: split agreed with the Gitaly team based on RPC volume by calling feature. Revisit when the CI storage tier moves off Gitaly. |||,},Produces one vector(<weight>) rule per key, sorted by feature category. Weights
are relative: they need not sum to one, the warehouse normalizes. Values must be
non-negative and at least one must be above zero; a zero weight is emitted as
declared. The note is required and should be dated and say where the numbers
came from, since nothing in the metric will.
costAttribution: { model: 'none', note: ||| Internal monitoring infrastructure that serves engineering, not a product feature category. Its cost is deliberately reported as unattributed. |||,},Produces no rule. The note is required.
Usage forms for proportional
Section titled “Usage forms for proportional”usage accepts any metrics-catalog metric object that exposes
aggregatedRateQuery, which is the same interface an SLI’s requestRate uses. The
generator calls it with ['feature_category'], the tenant selector and 5m, and
never builds PromQL itself.
| Counter | Gauge | Constant | |
|---|---|---|---|
usage | rateMetric(counter) | gaugeMetric(gauge) | direct / weighted |
| Recorded expr | sum by (fc) (rate(c[5m])) | sum by (fc) (avg_over_time(g[5m])) | vector(w) |
| Sample at t | ops/s | bytes | w |
| Warehouse, 1d | ops/day | byte-seconds/day | w x 86400 |
A missing sample counts as zero usage for every form.
rateMetric (or derivMetric) for a counter:
usage: rateMetric( counter='gitlab_sql_duration_seconds_sum', selector={ db_config_name: { re: 'main(_replica)?' } },),gaugeMetric for storage-shaped signals. Leave samplingInterval at its
default; it exists for precomputed Stackdriver rates. gaugeMetric has no
wrapFunctions, so a gauge that needs label_replace uses customRateQuery.
usage: gaugeMetric( gauge='gitlab_storage_bytes_by_feature_category', selector={ storage_class: 'STANDARD' },),customRateQuery for anything neither constructor can express, including a
read of an existing recording rule. It ignores the selector and range interval it
is given, so env="gprd" is not merged in: the owner must scope the
environment in the query, and the validator cannot check that. The reviewer
should. This is how patroni declares its model, reading the rate that
rails.yml already records once a
minute, summed over pods:
usage: customRateQuery(||| controller_action:gitlab_sql_duration_seconds_sum:rate5m{db_config_name=~"main(_replica)?",env="gprd"}|||),Or to shape a label the way the warehouse needs it:
usage: customRateQuery(||| label_replace( avg_over_time(stackdriver_gcs_bucket_storage_googleapis_com_storage_total_bytes{env="gprd"}[5m]), "feature_category", "$1", "bucket", "gitlab-gprd-(.*)" )|||),costAttribution.fromSLIs to reuse the service’s own SLIs. This is how web
declares its model: the request rate that drives the error budget also drives the
cost split, there is no metric name or selector to get wrong or to drift from the
SLI, and the rule reads the SLI’s sli_aggregations: recording rule instead of
rescanning the raw metric.
local costAttribution = import 'servicemetrics/cost-attribution.libsonnet';
usage: costAttribution.fromSLIs(['rails_request']),Each named SLI must exist on the service, have a requestRate and list
feature_category in significantLabels. The SLI’s featureCategory may stay
not_owned; only the label matters here. One SLI is used as is. Several are
stacked with or under a throwaway _c label and then summed, rather than added
with +, so a feature category that only one of the SLIs reports is still counted.
Check what the SLI counts before reusing it. An SLI measures what matters for the
SLO, which is not always what costs money: patroni reads
controller_action:gitlab_sql_duration_seconds_sum:rate5m instead of
fromSLIs(['rails_primary_sql', 'rails_replica_sql']) because those SLIs count
queries rather than query time and exclude Sidekiq, one of the largest database
consumers. The selector of the SLI also carries over unchanged: web’s expression
has type="web" because the SLI has it, not because the generator added it.
The cost of the query
Section titled “The cost of the query”The rule runs every five minutes in the gitlab-gprd tenant. What it costs Mimir
depends on the usage form:
| Form | Rendered as | Touches every 5m |
|---|---|---|
fromSLIs | sli_aggregations:<metric>:rate_5m{<SLI selector>} | the recorded series, instant lookup |
customRateQuery over a recording rule | the recorded series, as written | the recorded series, instant lookup |
rateMetric, gaugeMetric, raw customRateQuery | the raw expression, rate(...) included | every raw series over a 5m window |
Reading a recorded series is cheap because the rate() and the fan-in over pods
and endpoints are already paid for, once a minute, by the recording rule; the
attribution rule reads the result and never rates the raw series again. Prefer
fromSLIs whenever the signal you want is an SLI of the service, and a
customRateQuery over an existing recording rule when it is not (patroni
reads controller_action:gitlab_sql_duration_seconds_sum:rate5m from
rails.yml). A hand-written rule can
be removed without anyone checking here, so name the dependency in the note or
a comment.
A rateMetric is deliberately not resolved through the recording rule
registry, even when some SLI records the same metric. The recorded series only
cover the union of the SLI selectors (the SQL SLIs exclude Sidekiq, for
instance), so resolving would silently narrow the model to whatever scope those
SLIs happen to have. rateMetric means this metric with this selector, and it
costs a raw scan of that metric’s series every five minutes. Keep the selector
narrow; the cost is driven by the cardinality of the series matched, not by the
size of the result.
If the metric you want is not an SLI metric yet, the cheap path is to give it one:
a requestRate-only SLI with userImpacting: false and no apdex or error rate is
enough for the sli_aggregations: rule to exist (the stackdriver SLI in
logging.jsonnet is the shape),
and then fromSLIs on it. The SLI’s selector becomes the model’s scope, so choose
it for the cost model, not only for the SLO.
What make generate produces
Section titled “What make generate produces”One file per declaring service, in the gitlab-gprd tenant only:
mimir-rules/gitlab-gprd/<type>/autogenerated-gitlab-gprd-<type>-cost-attribution-usage.ymlgroups: - interval: 5m name: 'Cost attribution usage: patroni' rules: - expr: | sum by (feature_category) ( controller_action:gitlab_sql_duration_seconds_sum:rate5m{db_config_name=~"main(_replica)?",env="gprd"} ) labels: type: patroni record: gitlab:feature_category:cost_attribution:usage:rate_5mThe generated file belongs in the same MR as the declaration;
make ensure-generated-content-up-to-date fails otherwise. A service that does not
list gitlab-gprd in its tenants produces nothing and gets a trace warning
during generation. Services in service-catalog.yml without a
metrics-catalog/services/*.jsonnet definition need a stub serviceDefinition
before they can declare anything, including none.
Inspecting the series
Section titled “Inspecting the series”In Grafana, with the mimir-gitlab-gprd datasource:
gitlab:feature_category:cost_attribution:usage:rate_5m{type="patroni"}For a proportional model, the share each category receives over a day is what the warehouse will compute:
sum_over_time(gitlab:feature_category:cost_attribution:usage:rate_5m{type="patroni"}[1d])/ ignoring (feature_category) group_leftsum without (feature_category) (sum_over_time(gitlab:feature_category:cost_attribution:usage:rate_5m{type="patroni"}[1d]))An empty feature_category value means the usage metric had samples without the
label; those fold into unattributed.