InfoGrab DocsInfoGrab Docs

집계 엔진

요약

Aggregation Framework는 서로 다른 데이터베이스 백엔드에서 분석 쿼리를 구축하기 위한 통합 인터페이스를 제공합니다. ActiveRecord 엔진(Gitlab::Database::Aggregation::ActiveRecord::Engine)은 ActiveRecord의 쿼리 인터페이스를 사용하여 PostgreSQL 쿼리를 생성합니다.

Aggregation Framework는 서로 다른 데이터베이스 백엔드에서 분석 쿼리를 구축하기 위한 통합 인터페이스를 제공합니다. PostgreSQL(ActiveRecord를 통해)과 ClickHouse를 모두 지원하며, 개발자가 메트릭, 차원, 필터를 포함한 재사용 가능한 집계 엔진을 정의할 수 있습니다.

ActiveRecord 엔진 정의#

ActiveRecord 엔진(Gitlab::Database::Aggregation::ActiveRecord::Engine)은 ActiveRecord의 쿼리 인터페이스를 사용하여 PostgreSQL 쿼리를 생성합니다.

ActiveRecord 엔진 예시#

class IssueAggregationEngine < Gitlab::Database::Aggregation::ActiveRecord::Engine
  filters do
    exact_match :project_id, :integer, description: 'Filter by project ID'
    exact_match :state, :string, description: 'Filter by issue state'
  end

  dimensions do
    column :author_id, :integer, description: 'Group by author'
    date_bucket :created_at, :date,
      parameters: { granularity: { in: %i[daily weekly monthly yearly], type: :string } },
      description: 'Group by creation date'
  end

  metrics do
    count description: 'Total number of issues'
    mean :weight, :float, description: 'Average issue weight'
  end
end

ActiveRecord 엔진은 단일 레벨 SQL 쿼리를 생성합니다:

SELECT
  "issues"."author_id" AS aeq_author_id,
  date_trunc('month', "issues"."created_at") AS aeq_created_at,
  COUNT(*) AS aeq_total_count,
  AVG("issues"."weight") AS aeq_mean_weight
FROM "issues"
WHERE "issues"."project_id" IN (1, 2, 3)
  AND "issues"."state" IN ('opened')
GROUP BY aeq_author_id, aeq_created_at
ORDER BY aeq_author_id, aeq_created_at

주요 특성:

  • 모든 칼럼에는 aeq_(Aggregation Engine Query) 접두사가 붙습니다. 이 접두사는 AggregationResult 객체가 제거합니다.
  • 필터는 WHERE 또는 HAVING 절로 적용됩니다
  • 차원은 GROUP BY 칼럼이 됩니다
  • 메트릭은 집계 함수(COUNT, AVG)를 사용합니다

사용 가능한 구성 요소#

count 메트릭#

COUNT(*)를 사용하여 행을 집계합니다.

Option Type Required Description
name Symbol No count 메트릭의 이름. 기본값: 'total'. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

mean 메트릭#

AVG()를 사용하여 평균값을 계산합니다.

Option Type Required Description
name Symbol Yes 평균을 낼 칼럼 이름. 식별자는 :mean_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다(예: JOIN에 사용)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

column 차원#

칼럼 값으로 결과를 그룹화합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 데이터 타입(:string, :integer, :datetime 등)
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다(예: JOIN에 사용)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

date_bucket 차원#

PostgreSQL의 date_trunc() 함수를 사용하여 시간 간격으로 결과를 그룹화합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 날짜/datetime 칼럼 이름
type Symbol Yes 데이터 타입. :date를 사용합니다. 버킷은 항상 일 단위 경계에서 시작하므로, 모든 세분도에서 값이 날짜로 직렬화됩니다.
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다
parameters Hash No 파라미터 설정(아래 참조)
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
granularity String daily, weekly, monthly, yearly monthly 그룹화를 위한 시간 간격

exact_match 필터#

WHERE column IN (...)을 사용하여 정확한 값으로 행을 필터링합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

ClickHouse 엔진 정의#

ClickHouse 엔진(Gitlab::Database::Aggregation::ClickHouse::Engine)은 ClickHouse의 칼럼형 데이터베이스에 최적화된 쿼리를 생성합니다.

ClickHouse 엔진 예시#

class SessionAnalyticsEngine < Gitlab::Database::Aggregation::ClickHouse::Engine
  self.table_name = 'sessions'

  filters do
    exact_match :flow_type, :string, description: 'Filter by flow type'
    range :created_at, :datetime, description: 'Filter by creation date'
  end

  dimensions do
    column :flow_type, :string, description: 'Group by flow type'
    date_bucket :created_at, :date,
      parameters: { granularity: { in: %i[daily weekly monthly], type: :string } },
      description: 'Group by date'
  end

  metrics do
    count description: 'Total sessions'
    count :completed, :integer,
      expression: -> { Arel.sql('1') },
      if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Completed sessions'
    mean :duration, :float,
      expression: -> { Arel.sql('finished_at - created_at') },
      if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Average session duration'
    rate :completion,
      numerator_if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Session completion rate'
    quantile :duration, :float,
      expression: -> { Arel.sql('finished_at - created_at') },
      parameters: { quantile: { type: :float, description: 'Quantile value (0.0-1.0)' } },
      description: 'Duration percentile'
  end
end

ClickHouse 엔진은 최적의 성능을 위해 두 단계로 중첩된 쿼리를 생성합니다. 전체 구조는 다음과 같이 표현할 수 있습니다:

-- metacode query to emphasize on query structure
SELECT dimensions, metrics
FROM (
  SELECT
    primary_key_columns,
    dimensions_expressions,
    metrics_expressions,
  FROM source_table
  WHERE filters
  GROUP BY ALL
) ch_aggregation_inner_query
GROUP BY ALL
ORDER BY orders

내부 쿼리는 소스 테이블의 각 기본 키에 대한 데이터를 사전 계산합니다. 외부 쿼리는 내부 쿼리를 기반으로 메트릭과 차원을 계산합니다.

전체 쿼리 예시:

SELECT
  `ch_aggregation_inner_query`.`aeq_flow_type` AS aeq_flow_type,
  toStartOfInterval(
    `ch_aggregation_inner_query`.`aeq_created_at`,
    INTERVAL 1 month
  ) AS aeq_created_at,
  COUNT(*) AS aeq_total_count,
  countIf(`ch_aggregation_inner_query`.`aeq_completed_secondary` = 1) AS aeq_completed_count,
  avgIf(
    `ch_aggregation_inner_query`.`aeq_mean_duration`,
    `ch_aggregation_inner_query`.`aeq_mean_duration_secondary` = 1
  ) AS aeq_mean_duration,
  countIf(`ch_aggregation_inner_query`.`aeq_completion_rate` = 1) / COUNT(*) AS aeq_completion_rate,
  quantile(0.5)(`ch_aggregation_inner_query`.`aeq_duration_quantile`) AS aeq_duration_quantile
FROM (
  SELECT
    `sessions`.`flow_type` AS aeq_flow_type,
    `sessions`.`created_at` AS aeq_created_at,
    finished_at IS NOT NULL AS aeq_completed_secondary,
    finished_at - created_at AS aeq_mean_duration,
    finished_at IS NOT NULL AS aeq_mean_duration_secondary,
    finished_at IS NOT NULL AS aeq_completion_rate,
    finished_at - created_at AS aeq_duration_quantile,
    `sessions`.`user_id`,
    `sessions`.`session_id`
  FROM `sessions`
  WHERE `sessions`.`created_at` BETWEEN '2024-01-01' AND '2024-12-31'
  GROUP BY ALL
) ch_aggregation_inner_query
GROUP BY ALL
ORDER BY aeq_flow_type, aeq_created_at

주요 특성:

  • 두 단계 쿼리 구조(내부 쿼리 + 외부 집계)
  • 내부 쿼리는 행 수준 계산 및 기본 키 그룹화를 처리합니다. 외부 쿼리는 최종 집계를 수행합니다. 이 방식을 사용하면 *Merge 칼럼과 *If 집계를 쉽게 활용할 수 있습니다.
  • 조건부 메트릭은 *If 함수를 사용합니다
  • 모든 칼럼에는 aeq_(Aggregation Engine Query) 접두사가 붙습니다. 이 접두사는 AggregationResult 객체가 제거합니다.
  • 칼럼 필터는 내부 쿼리에서 WHERE 또는 HAVING 절로 적용됩니다
  • 메트릭 필터는 외부 쿼리에서 HAVING 절로 적용됩니다
  • 차원은 외부 쿼리의 GROUP BY 칼럼이 됩니다
  • 메트릭은 외부 쿼리의 집계 함수를 사용합니다

측정값(Measurements)#

측정값은 기본 타입을 갖는 행 수준 값입니다. 클래스 수준의 measurement 매크로로 한 번 선언하면, 프레임워크가 이를 관련된 메트릭 그룹으로 확장합니다.

행 수준 값을 노출할 때는 측정값을 사용하는 것이 좋습니다. 동일한 값에 대해 개별 메트릭을 정의하는 것은 측정값이 적합하지 않을 때만, 예를 들어 if: 조건, 포맷터, 또는 이 매크로가 생성하지 않는 집계가 필요할 때만 사용합니다.

measurement(name, type, expression, description: nil)

이 매크로는 점(dot)이 포함된 식별자를 가진 메트릭 그룹으로 확장됩니다:

  • <name>.min과 <name>.max. 측정값의 기본 타입을 상속합니다
  • <name>.mean. 항상 :float입니다
  • <name>.quantile. 항상 :float이며, float 타입의 quantile 파라미터가 자동으로 선언되고, 허용 범위는 0.0부터 1.0이며 기본값은 0.5입니다
  • <name>.sum. 측정값의 기본 타입을 상속합니다. sum은 합산 가능한 기본 타입(:integer와 :float)에 대해서만 생성됩니다.

expression 인수로는 임시 칼럼 참조 또는 람다를 사용할 수 있습니다. 인수가 없는 람다는 자동으로 래핑됩니다.

이 매크로는 또한 표현식을 측정값 이름으로 임시 칼럼에 등록하므로, 이후에 오는 정의가 transient(:name)으로 이를 재사용할 수 있습니다. 동일한 이름의 임시 칼럼이 이미 존재하면, 매크로는 기존 임시 칼럼을 유지합니다.

transient(:duration) do
  sql("dateDiff('seconds', anyIfMerge(created_event_at), anyIfMerge(finished_event_at))")
end

measurement :duration, :integer, transient(:duration), description: 'Session duration in seconds'

GraphQL에서는 이 집계들이 측정값 이름을 딴 하나의 중첩 그룹 필드로 노출되며, min, max, mean, quantile, sum 하위 필드를 가집니다.

duration {
  min
  max
  mean
  quantile(quantile: 0.95)
  sum
}

orderBy는 점이 포함된 전체 식별자를 허용합니다. 예를 들어 { identifier: "duration.max", direction: DESC }.

요구 사항 및 제한 사항#

  • 엔진 어댑터는 min, max, mean, quantile 메트릭을 지원해야 하며, 합산 가능한 측정값 타입에 대해서는 sum 메트릭도 지원해야 합니다. 이를 지원하지 않는 어댑터(현재는 ActiveRecord 엔진)에서 measurement를 호출하면 ArgumentError가 발생합니다. 실질적으로 이 매크로는 ClickHouse 전용입니다.
  • 이 매크로는 if: 또는 formatter: 옵션을 허용하지 않습니다.
  • 원시 측정값에는 차원이 부여되지 않습니다.
  • metric_range와 metric_exact_match 필터는 점이 포함된 메트릭 식별자를 대상으로 할 수 없습니다.

사용 가능한 구성 요소#

count 메트릭#

countIf()를 사용하여 고유 집계 및 조건부 집계를 지원하며 행을 집계합니다.

Option Type Required Description
name Symbol No count 메트릭의 이름. 기본값: 'total'. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 특정 값을 집계하기 위한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(countIf)
distinct Boolean No 고유 집계를 활성화합니다. 기본값: false
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

mean 메트릭#

avgIf()를 사용하여 조건부 평균을 지원하며 평균값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :mean_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 평균을 낼 값에 대한 커스텀 표현식
if Proc No 조건부 평균을 위한 조건 표현식(avgIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

min 메트릭#

minIf()를 사용하여 조건부 집계를 지원하며 최솟값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :min_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(minIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

max 메트릭#

maxIf()를 사용하여 조건부 집계를 지원하며 최댓값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :max_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(maxIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

rate 메트릭#

분자 조건을 충족하는 행과 분모 조건(또는 전체 행)을 충족하는 행 사이의 비율을 계산합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_rate가 됩니다
type Symbol No 데이터 타입. 기본값: :float
numerator_if Proc Yes 분자에 대한 조건(집계할 행)
denominator_if Proc No 분모에 대한 조건. 제공하지 않으면 전체 행 수를 사용합니다
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

quantile 메트릭#

ClickHouse의 quantile() 함수를 사용하여 백분위수를 계산합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :{name}_quantile이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
parameters Hash No 파라미터 설정(아래 참조)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
quantile Float 0.0 - 1.0 0.5 분위수 값(0.5 = 중앙값, 0.9 = p90, 0.99 = p99)

retained_count 메트릭#

groupBitmapState와 arrayIntersect를 사용하여 현재 기간과 이전 기간 모두에 나타나는 값을 집계합니다. 기능 유지율 또는 재방문 사용자 수를 구할 때는 retained_count를 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  retained_count :returning_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users present in both the current and previous period'
end

lagged_count 메트릭#

lagInFrame과 함께 uniqExact를 사용하여 이전 기간의 고유 값 개수를 반환합니다. 보존율(재방문 ÷ 이전 기간)을 계산할 때는 lagged_count와 retained_count를 함께 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식
over Symbol Yes 기간을 정의하는 차원
lag_offset Integer No 되돌아볼 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  lagged_count :previous_period_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Distinct users in the previous period'
end

요청에 over: 외의 차원이 추가로 포함된 경우, 프레임워크는 그 추가 차원으로 lag 윈도우를 분할합니다. 각 조합은 독립적인 시퀀스를 가지므로, 값이 카테고리 간에 섞이지 않습니다. 예를 들어 dimensions: [feature, timestamp]이고 timestamp가 granularity: 'daily'인 date_bucket이며 메트릭이 over: :timestamp를 사용하는 경우, 생성된 SQL에는 OVER (PARTITION BY aeq_feature ORDER BY aeq_timestamp_daily ASC)가 포함됩니다. code_suggestions의 보존율은 chat의 보존율과 섞이지 않습니다.

acquired_count 메트릭#

현재 기간에는 존재하지만 이전 기간에는 없었던 고유 값을 집계합니다. groupArray와 arrayDistinct를 사용하여, 현재 기간의 고유 값 수에서 arrayIntersect 길이를 뺍니다. lagInFrame이 이전 기간을 제공합니다. 신규 사용자 수에는 acquired_count를 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  acquired_count :new_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users active in the current period but not in the previous one'
end

churned_count 메트릭#

이전 기간에는 존재했지만 현재 기간에는 없는 고유 값을 집계합니다. groupArray와 arrayDistinct를 사용하여, 이전 기간의 고유 값 수에서 arrayIntersect 길이를 뺍니다. lagInFrame이 이전 기간을 제공합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  churned_count :churned_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users active in the previous period but not in the current one'
end

retained_count, acquired_count, churned_count는 값의 전체 첫 등장이나 마지막 등장이 아니라, 오직 직전 기간과의 상대적 변화만을 측정합니다. 공백 기간 이후 돌아온 사용자는 다시 획득된 것으로 집계되고, 한 기간을 건너뛴 사용자는 그 기간에 이탈한 것으로 집계됩니다.

요청된 범위의 첫 번째 기간에는 직전 기간이 없으므로, 해당 기간의 모든 값은 획득된 것으로 집계되고 이탈 수는 0이 됩니다. 이는 retained_count가 해당 기간에 0을 반환하는 것과 동일합니다. 행이 없는 기간은 결과 집합에서 빠지므로, lag는 마지막으로 행이 있던 기간과 비교됩니다. 이탈은 행이 하나 이상 있는 기간에서만 나타납니다. 행이 0개인 기간은 결과 행 자체를 만들지 않기 때문입니다.

어느 기간에서든 retained_count와 acquired_count의 합은 현재 기간의 고유 값 수와 같고, retained_count와 churned_count의 합은 lagged_count와 같습니다. 따라서 추가 쿼리 없이도 단일 응답에서 이탈률을 도출할 수 있습니다.

column 차원#

칼럼 값으로 결과를 그룹화합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 데이터 타입(:string, :integer, :datetime 등)
expression Proc No 칼럼 대신 사용할 커스텀 표현식
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명
association Boolean No true로 설정하면 _id 접미사 없이 객체로도 차원에 접근할 수 있습니다. 기본값: false.

date_bucket 차원#

ClickHouse의 toStartOfInterval() 함수를 사용하여 시간 간격으로 결과를 그룹화합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 날짜/datetime 칼럼 이름
type Symbol Yes 데이터 타입. :date를 사용합니다. 버킷은 항상 일 단위 경계에서 시작하므로, 모든 세분도에서 값이 날짜로 직렬화됩니다.
expression Proc No 칼럼 대신 사용할 커스텀 표현식
parameters Hash No 파라미터 설정(아래 참조)
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
granularity String daily, weekly, monthly, yearly monthly 그룹화를 위한 시간 간격

tier 차원#

클라이언트가 제공하는 오름차순 정수 임계값을 기준으로, ClickHouse의 multiIf() 함수를 사용하여 숫자 표현식을 서열형 티어(tier_0부터 tier_N까지)로 나눕니다. 파라미터를 지원합니다.

첫 번째 임계값보다 작은 값은 tier_0에 속합니다. 마지막 임계값 이상인 값은 최상위 티어에 속하므로, N개의 임계값은 N+1개의 티어(tier_0부터 tier_N까지)를 만듭니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 출력 레이블의 데이터 타입(:string 사용)
expression Proc No 칼럼 대신 나눌 숫자 표현식
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
thresholds Array of Integers 최대 9개까지의 순수 오름차순 양의 정수 None(필수) 티어 경계

thresholds 파라미터는 자동으로 선언되며, 이 차원을 사용하는 모든 요청에서 필수입니다. 임계값의 정규화(예: 주 단위 스케일링)는 클라이언트가 처리할 사항입니다.

dimensions do
  tier :user_tier, :string, -> { sql('user_activity.sessions') }, ctes: [:user_activity]
end

exact_match 필터#

정확한 값으로 행을 필터링합니다. 일반 칼럼 또는 merge 칼럼(사전 집계된 데이터)에 대한 필터링을 지원합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입
expression Proc No 칼럼 대신 사용할 커스텀 표현식
merge_column Boolean No true이면 WHERE 대신 HAVING을 사용하여 필터를 적용합니다
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

range 필터#

BETWEEN을 사용하여 값 범위로 행을 필터링합니다. 일반 칼럼 또는 merge 칼럼에 대한 필터링을 지원합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입(:datetime, :integer 등)
expression Proc No 칼럼 대신 사용할 커스텀 표현식
merge_column Boolean No true이면 WHERE 대신 HAVING을 사용하여 필터를 적용합니다
description String No 사람이 읽을 수 있는 설명

metric_exact_match 필터#

집계된 메트릭 값의 정확한 일치로 그룹을 필터링합니다. 집계 이후 HAVING 절로 적용됩니다.

Option Type Required Description
name Symbol Yes 필터링할 메트릭의 식별자. 같은 엔진에 정의된 메트릭과 일치해야 합니다.
type Symbol Yes 필터 값의 데이터 타입
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

참조된 메트릭은 동일한 Request에서도 요청되어야 합니다. 파라미터화된 메트릭의 경우, 필터의 parameters는 요청된 메트릭 인스턴스의 파라미터와 일치해야 합니다.

예시:

filters do
  metric_exact_match :total_count, :integer
end
Gitlab::Database::Aggregation::Request.new(
  filters: [{ identifier: :total_count, values: [1, 2] }],
  dimensions: [{ identifier: :user_id }],
  metrics: [{ identifier: :total_count }]
)

metric_range 필터#

BETWEEN을 사용하여 집계된 메트릭의 값 범위로 그룹을 필터링합니다. 집계 이후 HAVING 절로 적용됩니다.

Option Type Required Description
name Symbol Yes 필터링할 메트릭의 식별자. 같은 엔진에 정의된 메트릭과 일치해야 합니다.
type Symbol Yes 필터 값의 데이터 타입(:integer, :float 등)
description String No 사람이 읽을 수 있는 설명

참조된 메트릭은 동일한 Request에서도 요청되어야 합니다. 파라미터화된 메트릭의 경우, 필터의 parameters는 요청된 메트릭 인스턴스의 파라미터와 일치해야 올바른 메트릭 인스턴스를 대상으로 합니다.

예시:

filters do
  metric_range :total_count, :integer
  metric_range :duration_quantile, :float
end
Gitlab::Database::Aggregation::Request.new(
  filters: [
    { identifier: :duration_quantile, parameters: { quantile: 0.1 }, values: 200..nil }
  ],
  dimensions: [{ identifier: :user_id }],
  metrics: [{ identifier: :duration_quantile, parameters: { quantile: 0.1 } }]
)

임시 칼럼(Transient columns)#

임시 칼럼은 한 번 정의하고 dimensions, metrics, filters 블록 전반에서 참조할 수 있는 명명된 SQL 표현식 별칭입니다. 최종 쿼리 결과에는 프로젝션되지 않습니다. 복잡한 SQL 표현식의 중복을 없애려면 임시 칼럼을 사용합니다.

임시 칼럼 정의#

클래스 수준에서 이름과 Arel 표현식을 반환하는 블록으로 transient를 호출합니다. 참조하기 전에 먼저 임시 칼럼을 정의합니다.

transient(:duration) do
  sql("dateDiff('seconds', anyIfMerge(created_event_at), anyIfMerge(finished_event_at))")
end

transient(:is_finished) { sql('anyIfMerge(finished_event_at) IS NOT NULL') }

임시 칼럼 참조#

dimensions, metrics, filters 블록 안에서 transient(:name)을 호출하여 저장된 표현식을 삽입합니다. 반환값은 람다 표현식이 허용되는 모든 곳, 즉 위치 인수나 키워드 인수 값으로 전달할 수 있습니다.

metrics do
  mean :duration, :float, transient(:duration),
    description: 'Average session duration in seconds'

  count :finished, if: transient(:is_finished),
    description: 'Number of finished sessions'
end

보조 CTE(베타, 제한된 기능, ClickHouse 전용)#

보조 CTE는 다른 테이블에 대한 키별 요약(예: 사용자별 요약)입니다. 프레임워크는 이를 메인 쿼리에 JOIN으로 결합합니다. 이를 통해 dimensions, filters, metrics가 CTE 집계를 참조할 수 있습니다. 버전 1은 동일 테이블 계약에 따라, 엔진 자체 테이블에 대한 요약만 지원합니다. 지원되는 테이블 엔진은 MergeTree와 ReplacingMergeTree뿐입니다. 이 기능은 ClickHouse 엔진에서만 사용할 수 있습니다.

보조 CTE 정의#

table_name을 설정한 이후 클래스 수준에서 보조 CTE를 선언합니다. supporting_cte :name, join_key: :user_id, join_type: :inner do |qb| ... end를 사용합니다. 블록은 준비된 기본 스코프에 대한 쿼리 빌더를 받습니다. 블록은 오직 qb.select(...)를 통해 집계 프로젝션만 추가해야 합니다. 프레임워크는 join key 칼럼과 GROUP BY join_key를 자동으로 추가합니다.

CTE는 이미 준비된 기본 쿼리의 복사본으로부터 쿼리 시점에 구성됩니다. 엔진의 기본 스코프, ReplacingMergeTree 테이블의 중복 제거 서브쿼리, 그리고 요청의 모든 행 필터를 상속합니다. CTE 자체가 CTE 기반인 필터는 CTE 본문으로 전파되지 않습니다. 이런 필터는 메인 쿼리에만 적용됩니다.

join_type: 파라미터는 :inner(기본값) 또는 :outer를 허용하며, :outer는 LEFT OUTER JOIN으로 렌더링됩니다. 기본값인 inner join에서는, join key가 NULL인 기본 행이 CTE 행과 절대 일치하지 않으므로 제외됩니다. join key가 null을 허용하는 엔진에는 :outer를 사용합니다.

class DuoWorkflowsEngine < Gitlab::Database::Aggregation::ClickHouse::Engine
  self.table_name = 'duo_workflows_workflows_enriched'

  supporting_cte :user_activity_cte, join_key: :user_id do |qb|
    qb.select(
      qb.count.as('workflows'),
      qb.named_func('uniqExact', [qb[:workflow_definition]]).as('flow_types')
    )
  end

  dimensions do
    column :user_tier, :string, -> {
      sql("multiIf(user_activity.workflows >= 5, 'heavy', user_activity_cte.workflows >= 2, 'medium', 'light')")
    }, ctes: [:user_activity_cte]
  end

  filters do
    range :flow_types_used, :integer, -> { sql('user_activity_cte.flow_types') }, ctes: [:user_activity_cte]
  end

  metrics do
    count
    count :users, :integer, -> { sql('user_id') }, distinct: true
  end
end

보조 CTE 참조#

차원, 필터, 메트릭에 ctes: :name 또는 ctes: [:a, :b]를 추가하여 CTE를 선택적으로 사용합니다. 선언되지 않은 CTE 이름을 참조하면 클래스 정의 시점에 ArgumentError가 발생합니다. 참조된 각 CTE는 여러 부분이 참조하더라도 쿼리당 정확히 한 번만 구성되고 JOIN됩니다.

표현식은 스코프에 CTE가 하나뿐이더라도, user_activity.workflows처럼 CTE 이름으로 CTE 칼럼을 한정해야 합니다. CTE를 기반으로 하는 필터는 JOIN된 요약 칼럼에 대한 일반 WHERE 절이 되며, HAVING 절이 되지 않습니다. 이 덕분에 "사용자별 조건과 일치하는 사용자 수"를 단일 숫자 쿼리로 만들 수 있습니다.

다음 요청은 단일 숫자, 즉 두 가지 이상의 플로 유형을 사용한 사용자 수를 반환합니다.

Gitlab::Database::Aggregation::Request.new(
  filters: [{ identifier: :flow_types_used, values: 2..nil }],
  metrics: [{ identifier: :users_count }]
)

쿼리 비용#

참조된 각 CTE는 기본 스코프에 대한 추가 스캔 한 번과, 인메모리 해시 JOIN을 더합니다. 단일 칼럼 CTE 여러 개보다는, 집계 칼럼 여러 개를 가진 CTE 하나를 선호합니다.

프레임워크 사용#

집계 요청 생성#

request = Gitlab::Database::Aggregation::Request.new(
  filters: [
    { identifier: :project_id, values: [1, 2, 3] },
    { identifier: :state, values: ['opened'] }
  ],
  dimensions: [
    { identifier: :author_id },
    { identifier: :created_at, parameters: { granularity: 'monthly' } },
    { identifier: :created_at, parameters: { granularity: 'weekly' } },
  ],
  metrics: [
    { identifier: :total_count },
    { identifier: :mean_weight }
  ],
  order: [
    { identifier: :total_count, direction: :desc } # order identifier must reference dimension or metric.
  ]
)

엔진으로 요청 실행#

engine = IssueAggregationEngine.new(context: { scope: Issue.all })
response = engine.execute(request)

if response.success?
  puts "Success: #{response.payload[:data].to_a.inspect}"
else
  puts "Errors: #{response.errors}"
end
  • 엔진에는 기본 스코프가 제공되어야 합니다. 사용 사례에 따라 현재 프로젝트, 네임스페이스, 사용자 등으로 이미 사전 필터링된 스코프를 제공할 수 있습니다.
  • 모든 요청 필터는 제공된 기본 스코프에 적용됩니다.

아키텍처 개요#

프레임워크는 다음과 같은 주요 구성 요소로 이루어져 있습니다:

  • Engine: 특정 데이터 소스에 대해 사용 가능한 메트릭, 차원, 필터를 정의하는 핵심 클래스
  • Request: 선택된 메트릭, 차원, 필터, 정렬을 포함하는 쿼리 요청을 나타냅니다
  • QueryPlan: 요청을 검증하고 실행 가능한 쿼리 부분으로 변환합니다
  • AggregationResult: 쿼리 실행과 결과 포맷을 처리합니다
┌────────────────────────────────────────────────────┐
│                        Request                     │
│  (metrics, dimensions, filters, order)             │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                        QueryPlan                   │
│  (validates request, builds plan parts)            │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                         Engine                     │
│  (executes query plan, returns AggregationResult)  │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                   AggregationResult                │
│ implements Enumerable to access formatted results  │
└────────────────────────────────────────────────────┘

유효성 검사#

프레임워크는 실행 전에 요청을 검증합니다:

  • 최소 하나의 메트릭이 필요합니다
  • 참조된 모든 식별자는 엔진 정의에 존재해야 합니다
  • 파라미터는 선언된 유효성 검사 조건을 충족해야 합니다. 예를 들어 granularity: { in: %i[daily weekly monthly], type: :string }는 granularity 값이 제공된 3개의 문자열 중 하나여야 함을 요구합니다.

GraphQL 통합#

Gitlab::Database::Aggregation::Graphql::Mounter 모듈을 사용하여 GraphQL API에서 집계 엔진을 노출합니다.

GraphQL 통합은 다음을 자동으로 생성합니다:

  • Query field: 마운트된 엔진에 대한 쿼리 필드
  • Filter arguments: 엔진 필터 정의를 기반으로 한 필터 인수
  • Order argument: 엔진 차원 및 메트릭 정의를 기반으로 한 정렬 인수. 스네이크 케이스로 변환된 차원 및 메트릭 식별자를 정렬 식별자로 사용할 수 있습니다
  • Response types: 차원과 메트릭을 필드로 포함하는 응답 타입. 점이 포함된 식별자를 가진 메트릭은 공유 그룹 필드 아래에 중첩됩니다
  • Parameterized fields: 파라미터가 있는 차원 및 메트릭을 위한 파라미터화된 필드
  • Pagination: 집계 결과는 OFFSET 페이지네이션을 사용하여 자동으로 페이지 처리됩니다

엔진 마운트#

GraphQL 타입에서 mount_aggregation_engine 메서드를 사용하여 집계 엔진을 노출합니다:

module Types
  class ProjectType < BaseObject
    extend Gitlab::Database::Aggregation::Graphql::Mounter

    mount_aggregation_engine(
      IssueAggregationEngine,
      field_name: 'issue_analytics',
      description: 'Issue analytics aggregation'
    ) do
      # Define base aggregation scope. Build your own scope or inherit one from parent object.
      def aggregation_scope
        object.issues
      end
    end
  end
end
Note

모든 필터, 메트릭, 차원은 자동으로 노출됩니다.

Mounter 옵션#

Option Type Description
field_name String/Symbol GraphQL 필드 이름. 기본값: :aggregation
types_prefix String/Symbol *AggregationResponse와 같은 모든 자식 타입의 접두사. 기본값: field_name
description String GraphQL 필드에 대한 설명
authorize Symbol 필드에 접근하기 위해 필요한 권한(예: :read_project). GraphQL 필드 정의에 직접 전달됩니다

인가#

authorize 옵션을 사용하여 필드에 대한 접근을 제한합니다:

mount_aggregation_engine(
  IssueAggregationEngine,
  field_name: 'issue_analytics',
  description: 'Issue analytics aggregation',
  authorize: :read_project
) do
  # authorize :read_project - this also supported.
  def aggregation_scope
    object.issues
  end
end

authorize를 지정하지 않으면 인가를 수동으로 처리해야 합니다.

부분 단위 인가#

개별 메트릭, 차원, 필터는 위에서 설명한 필드 수준 authorize와는 별도로, 추가적인 가시성 검사를 요구하는 자체 authorize: 옵션을 선언할 수 있습니다:

metrics do
  count :total_count, :integer
  count :owner_count, :integer, authorize: :owner_access
end

dimensions do
  column :status, :string
  column :internal_flag, :string, authorize: ->(user, resources) { resources.all? { |r| r.member?(user) } }
end

authorize:는 능력(ability) 심볼을 받을 수 있으며, 이 경우 엔진 컨텍스트의 authorization_resources에 있는 모든 리소스에 대해 Ability.allowed?(user, ability, resource)로 검사됩니다. 또는 (user, resources)로 한 번 호출되어 불리언을 반환하고 모든 리소스의 인가를 스스로 책임지는 콜러블을 받을 수도 있습니다. measurement 매크로도 authorize:를 받아, 확장된 모든 점 포함 메트릭(.min, .max, .mean, .quantile, .sum)에 이를 전파합니다.

사용자가 특정 부분에 대해 인가되지 않은 경우:

  • 보호된 메트릭은 요청에서 조용히 제외되고, 해당 필드는 null을 반환합니다. 응답 형태는 변경되지 않습니다.
  • 보호된 차원, 필터, 정렬은 명확한 오류와 함께 요청 검증에 실패합니다.

GraphQL 쿼리 예시#

생성된 GraphQL 서브트리는 두 단계 구조를 사용합니다:

  • 외부 필드(issueAnalytics)는 차원 및 비메트릭 필터 인수를 허용합니다.
  • 내부 aggregated 필드는 메트릭 필터, 정렬, 페이지네이션 인수를 허용하고, 페이지 처리된 커넥션을 반환합니다.
query IssueAnalytics($projectId: ID!) {
  project(fullPath: $projectId) {
    issueAnalytics(
      state: ["opened", "closed"]
      createdAtFrom: "2024-01-01"
      createdAtTo: "2024-12-31"
    ) {
      aggregated(
        totalCountFrom: 5
        orderBy: [{ identifier: "totalCount", direction: DESC }]
        first: 10
      ) {
        nodes {
          dimensions {
            createdAt(granularity: "monthly")
          }
          totalCount
          meanWeight
          highQuantile: durationQuantile(0.9)
          medianQuantile: durationQuantile(0.5)
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
}

필터 배치#

필터 인수는 필터가 적용되는 시점을 기준으로 두 레벨에 분산됩니다:

  • 비메트릭 필터(exact_match 또는 range로 정의된 것)는 외부 필드(예: issueAnalytics)에 나타납니다.
  • 메트릭 필터(metric_exact_match 또는 metric_range로 정의된 것)는 내부 aggregated 필드에 나타납니다.

점이 포함된 메트릭 식별자#

동일한 기본 값의 여러 집계를 하나의 GraphQL 필드 아래에 묶으려면, 점이 포함된 이름으로 메트릭을 선언합니다. 이 이름은 식별자로 그대로 사용됩니다(일반적인 :{name}_count 형식의 파생은 건너뜁니다):

metrics do
  mean :"duration.mean", :float, ->(_params) { Arel.sql("dateDiff('seconds', created_at, finished_at)") }
  quantile :"duration.quantile", :float, ->(_params) { Arel.sql("dateDiff('seconds', created_at, finished_at)") },
    parameters: { quantile: { type: :float } }
end

접두사를 공유하는 모든 메트릭은 중첩된 dimensions 하위 객체와 유사하게, 두 번째 세그먼트마다 하위 필드를 가진 하나의 객체 필드가 됩니다. 파라미터가 있는 메트릭은 하위 필드에 인수를 유지합니다:

nodes {
  duration {
    mean
    quantile(quantile: 0.9)
  }
}

명시적 표현식이 없는 점 포함 메트릭은 첫 번째 세그먼트 이름의 데이터베이스 칼럼을 읽는 것으로 대체됩니다(예: duration.max는 duration 칼럼을 읽습니다). count 메트릭은 이름을 칼럼으로 참조하지 않습니다.

정의 시점에 적용되는 규칙:

  • 점으로 구분된 세그먼트가 정확히 두 개여야 하며, 각 세그먼트는 [a-z][a-z0-9_]*와 일치해야 합니다. 메트릭에만 적용되며, 차원과 필터는 점이 포함된 이름을 거부합니다.
  • 접두사는 플랫(flat) 메트릭 식별자와 충돌해서는 안 되며, dimensions는 예약어입니다.
  • 점 정규화 이후에도 식별자는 고유해야 합니다. 인스턴스 키(SQL 별칭 및 결과 행 키)는 .을 __로 치환하므로, duration.max는 duration__max 메트릭과 충돌합니다.

orderBy는 점이 포함된 전체 식별자를 허용합니다({ identifier: "duration.max", direction: DESC }). 메트릭 필터는 점이 포함된 메트릭을 대상으로 할 수 없습니다.

커스텀 요청 유효성 검사#

GraphQL 스키마를 유지하면서 특정 집계 요청을 거부하는 커스텀 유효성 검사 로직을 추가합니다. 특정 요청에 커스텀 런타임 제약을 적용해야 할 때 유용합니다.

GraphQL::ExecutionError를 발생시켜 커스텀 에러 메시지와 함께 요청을 거부할 수 있습니다.

커스텀 유효성 검사를 추가하려면 마운팅 블록에서 validate_request! 메서드를 오버라이드합니다:

module Types
  class ProjectType < BaseObject
    extend Gitlab::Database::Aggregation::Graphql::Mounter

    mount_aggregation_engine(IssueAggregationEngine) do
      # Other configuration options...
      # Custom validation logic
      def validate_request!(engine_request)
        if engine_request.dimensions.empty?
          raise GraphQL::ExecutionError, 'At least one dimension must be specified'
        end
      end
    end
  end
end

validate_request! 메서드는 dimensions, metrics, filters, order 사양을 포함하는 Gitlab::Database::Aggregation::Request 객체를 받습니다.

ActiveRecord 연관을 위한 차원#

차원은 association: true 옵션을 사용하여 연관으로 표시할 수 있습니다. 이렇게 하면 차원이 GraphQL에 노출되는 방식이 변경되어, ID만 노출하는 대신 연관된 모델을 자동으로 리졸브합니다.

연관 차원 정의#

집계 엔진에서 association: true를 사용하여 차원을 선언합니다:

class AgentPlatformSessions < Gitlab::Database::Aggregation::ClickHouse::Engine
  dimensions do
    column :flow_type, :string, description: 'Type of session'
    column :user_id, :integer, description: 'Session owner', association: true
  end
end

GraphQL 스키마에 미치는 영향#

차원이 연관으로 표시되면, 원시 *_id 필드 대신 객체가 노출됩니다. 위의 차원은 GraphQL에서 ID로 배치 로딩하여 field :user, Types::UserType, ...으로 변환됩니다. 연관 이름에서 _id 접미사를 제거하여 연관 ID로 차원을 정렬할 수 있습니다(예: orderBy: [{ identifier: "user", direction: DESC }]).

Note

연관 GraphQL 타입에 대한 모든 적절한 인가 검사를 보장해야 합니다(예: authorize :read_user).

커스텀 연관 설정#

기본적으로 연관 모델과 GraphQL 타입은 차원 이름으로부터 추론됩니다:

  • Model: user_id → User
  • GraphQL type: User → Types::UserType

association 옵션에 해시를 전달하여 이 동작을 커스터마이징할 수 있습니다:

dimensions do
  column :author_id, :integer,
    description: 'Issue author',
    association: { model: User }
    # or model and GraphQL type
    # association: { model: User, graphql_type: Types::CurrentUserType }
end

GraphQL 쿼리 예시#

다음은 연관 없이 사용하는 쿼리 예시입니다:

query {
  project(fullPath: "gitlab-org/gitlab") {
    aiUsage {
      agentPlatformSessions {
        aggregated {
          nodes {
            dimensions {
              userId  # Returns: 123 (integer)
            }
          }
        }
      }
    }
  }
}

다음은 연관을 사용하는 쿼리 예시입니다:

query {
  project(fullPath: "gitlab-org/gitlab") {
    aiUsage {
      agentPlatformSessions(
        userId: [1, 2]  # Filter still uses original dimension identifier
      ) {
        aggregated(
          orderBy: [{ identifier: "user", direction: DESC }]  # Order uses association name
        ) {
          nodes {
            dimensions {
              user {  # Returns: full User object
                id
                username
                name
              }
            }
          }
        }
      }
    }
  }
}

관련 문서#

집계 엔진

GitLab v19.4
원문 보기

요약

Aggregation Framework는 서로 다른 데이터베이스 백엔드에서 분석 쿼리를 구축하기 위한 통합 인터페이스를 제공합니다. ActiveRecord 엔진(Gitlab::Database::Aggregation::ActiveRecord::Engine)은 ActiveRecord의 쿼리 인터페이스를 사용하여 PostgreSQL 쿼리를 생성합니다.

Aggregation Framework는 서로 다른 데이터베이스 백엔드에서 분석 쿼리를 구축하기 위한 통합 인터페이스를 제공합니다. PostgreSQL(ActiveRecord를 통해)과 ClickHouse를 모두 지원하며, 개발자가 메트릭, 차원, 필터를 포함한 재사용 가능한 집계 엔진을 정의할 수 있습니다.

ActiveRecord 엔진 정의#

ActiveRecord 엔진(Gitlab::Database::Aggregation::ActiveRecord::Engine)은 ActiveRecord의 쿼리 인터페이스를 사용하여 PostgreSQL 쿼리를 생성합니다.

ActiveRecord 엔진 예시#

class IssueAggregationEngine < Gitlab::Database::Aggregation::ActiveRecord::Engine
  filters do
    exact_match :project_id, :integer, description: 'Filter by project ID'
    exact_match :state, :string, description: 'Filter by issue state'
  end

  dimensions do
    column :author_id, :integer, description: 'Group by author'
    date_bucket :created_at, :date,
      parameters: { granularity: { in: %i[daily weekly monthly yearly], type: :string } },
      description: 'Group by creation date'
  end

  metrics do
    count description: 'Total number of issues'
    mean :weight, :float, description: 'Average issue weight'
  end
end

ActiveRecord 엔진은 단일 레벨 SQL 쿼리를 생성합니다:

SELECT
  "issues"."author_id" AS aeq_author_id,
  date_trunc('month', "issues"."created_at") AS aeq_created_at,
  COUNT(*) AS aeq_total_count,
  AVG("issues"."weight") AS aeq_mean_weight
FROM "issues"
WHERE "issues"."project_id" IN (1, 2, 3)
  AND "issues"."state" IN ('opened')
GROUP BY aeq_author_id, aeq_created_at
ORDER BY aeq_author_id, aeq_created_at

주요 특성:

  • 모든 칼럼에는 aeq_(Aggregation Engine Query) 접두사가 붙습니다. 이 접두사는 AggregationResult 객체가 제거합니다.
  • 필터는 WHERE 또는 HAVING 절로 적용됩니다
  • 차원은 GROUP BY 칼럼이 됩니다
  • 메트릭은 집계 함수(COUNT, AVG)를 사용합니다

사용 가능한 구성 요소#

count 메트릭#

COUNT(*)를 사용하여 행을 집계합니다.

Option Type Required Description
name Symbol No count 메트릭의 이름. 기본값: 'total'. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

mean 메트릭#

AVG()를 사용하여 평균값을 계산합니다.

Option Type Required Description
name Symbol Yes 평균을 낼 칼럼 이름. 식별자는 :mean_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다(예: JOIN에 사용)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

column 차원#

칼럼 값으로 결과를 그룹화합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 데이터 타입(:string, :integer, :datetime 등)
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다(예: JOIN에 사용)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

date_bucket 차원#

PostgreSQL의 date_trunc() 함수를 사용하여 시간 간격으로 결과를 그룹화합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 날짜/datetime 칼럼 이름
type Symbol Yes 데이터 타입. :date를 사용합니다. 버킷은 항상 일 단위 경계에서 시작하므로, 모든 세분도에서 값이 날짜로 직렬화됩니다.
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
scope_proc Proc No ActiveRecord 스코프를 수정합니다
parameters Hash No 파라미터 설정(아래 참조)
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
granularity String daily, weekly, monthly, yearly monthly 그룹화를 위한 시간 간격

exact_match 필터#

WHERE column IN (...)을 사용하여 정확한 값으로 행을 필터링합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입
expression Proc No 칼럼 대신 사용할 커스텀 Arel 표현식
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

ClickHouse 엔진 정의#

ClickHouse 엔진(Gitlab::Database::Aggregation::ClickHouse::Engine)은 ClickHouse의 칼럼형 데이터베이스에 최적화된 쿼리를 생성합니다.

ClickHouse 엔진 예시#

class SessionAnalyticsEngine < Gitlab::Database::Aggregation::ClickHouse::Engine
  self.table_name = 'sessions'

  filters do
    exact_match :flow_type, :string, description: 'Filter by flow type'
    range :created_at, :datetime, description: 'Filter by creation date'
  end

  dimensions do
    column :flow_type, :string, description: 'Group by flow type'
    date_bucket :created_at, :date,
      parameters: { granularity: { in: %i[daily weekly monthly], type: :string } },
      description: 'Group by date'
  end

  metrics do
    count description: 'Total sessions'
    count :completed, :integer,
      expression: -> { Arel.sql('1') },
      if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Completed sessions'
    mean :duration, :float,
      expression: -> { Arel.sql('finished_at - created_at') },
      if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Average session duration'
    rate :completion,
      numerator_if: -> { Arel.sql('finished_at IS NOT NULL') },
      description: 'Session completion rate'
    quantile :duration, :float,
      expression: -> { Arel.sql('finished_at - created_at') },
      parameters: { quantile: { type: :float, description: 'Quantile value (0.0-1.0)' } },
      description: 'Duration percentile'
  end
end

ClickHouse 엔진은 최적의 성능을 위해 두 단계로 중첩된 쿼리를 생성합니다. 전체 구조는 다음과 같이 표현할 수 있습니다:

-- metacode query to emphasize on query structure
SELECT dimensions, metrics
FROM (
  SELECT
    primary_key_columns,
    dimensions_expressions,
    metrics_expressions,
  FROM source_table
  WHERE filters
  GROUP BY ALL
) ch_aggregation_inner_query
GROUP BY ALL
ORDER BY orders

내부 쿼리는 소스 테이블의 각 기본 키에 대한 데이터를 사전 계산합니다. 외부 쿼리는 내부 쿼리를 기반으로 메트릭과 차원을 계산합니다.

전체 쿼리 예시:

SELECT
  `ch_aggregation_inner_query`.`aeq_flow_type` AS aeq_flow_type,
  toStartOfInterval(
    `ch_aggregation_inner_query`.`aeq_created_at`,
    INTERVAL 1 month
  ) AS aeq_created_at,
  COUNT(*) AS aeq_total_count,
  countIf(`ch_aggregation_inner_query`.`aeq_completed_secondary` = 1) AS aeq_completed_count,
  avgIf(
    `ch_aggregation_inner_query`.`aeq_mean_duration`,
    `ch_aggregation_inner_query`.`aeq_mean_duration_secondary` = 1
  ) AS aeq_mean_duration,
  countIf(`ch_aggregation_inner_query`.`aeq_completion_rate` = 1) / COUNT(*) AS aeq_completion_rate,
  quantile(0.5)(`ch_aggregation_inner_query`.`aeq_duration_quantile`) AS aeq_duration_quantile
FROM (
  SELECT
    `sessions`.`flow_type` AS aeq_flow_type,
    `sessions`.`created_at` AS aeq_created_at,
    finished_at IS NOT NULL AS aeq_completed_secondary,
    finished_at - created_at AS aeq_mean_duration,
    finished_at IS NOT NULL AS aeq_mean_duration_secondary,
    finished_at IS NOT NULL AS aeq_completion_rate,
    finished_at - created_at AS aeq_duration_quantile,
    `sessions`.`user_id`,
    `sessions`.`session_id`
  FROM `sessions`
  WHERE `sessions`.`created_at` BETWEEN '2024-01-01' AND '2024-12-31'
  GROUP BY ALL
) ch_aggregation_inner_query
GROUP BY ALL
ORDER BY aeq_flow_type, aeq_created_at

주요 특성:

  • 두 단계 쿼리 구조(내부 쿼리 + 외부 집계)
  • 내부 쿼리는 행 수준 계산 및 기본 키 그룹화를 처리합니다. 외부 쿼리는 최종 집계를 수행합니다. 이 방식을 사용하면 *Merge 칼럼과 *If 집계를 쉽게 활용할 수 있습니다.
  • 조건부 메트릭은 *If 함수를 사용합니다
  • 모든 칼럼에는 aeq_(Aggregation Engine Query) 접두사가 붙습니다. 이 접두사는 AggregationResult 객체가 제거합니다.
  • 칼럼 필터는 내부 쿼리에서 WHERE 또는 HAVING 절로 적용됩니다
  • 메트릭 필터는 외부 쿼리에서 HAVING 절로 적용됩니다
  • 차원은 외부 쿼리의 GROUP BY 칼럼이 됩니다
  • 메트릭은 외부 쿼리의 집계 함수를 사용합니다

측정값(Measurements)#

측정값은 기본 타입을 갖는 행 수준 값입니다. 클래스 수준의 measurement 매크로로 한 번 선언하면, 프레임워크가 이를 관련된 메트릭 그룹으로 확장합니다.

행 수준 값을 노출할 때는 측정값을 사용하는 것이 좋습니다. 동일한 값에 대해 개별 메트릭을 정의하는 것은 측정값이 적합하지 않을 때만, 예를 들어 if: 조건, 포맷터, 또는 이 매크로가 생성하지 않는 집계가 필요할 때만 사용합니다.

measurement(name, type, expression, description: nil)

이 매크로는 점(dot)이 포함된 식별자를 가진 메트릭 그룹으로 확장됩니다:

  • <name>.min과 <name>.max. 측정값의 기본 타입을 상속합니다
  • <name>.mean. 항상 :float입니다
  • <name>.quantile. 항상 :float이며, float 타입의 quantile 파라미터가 자동으로 선언되고, 허용 범위는 0.0부터 1.0이며 기본값은 0.5입니다
  • <name>.sum. 측정값의 기본 타입을 상속합니다. sum은 합산 가능한 기본 타입(:integer와 :float)에 대해서만 생성됩니다.

expression 인수로는 임시 칼럼 참조 또는 람다를 사용할 수 있습니다. 인수가 없는 람다는 자동으로 래핑됩니다.

이 매크로는 또한 표현식을 측정값 이름으로 임시 칼럼에 등록하므로, 이후에 오는 정의가 transient(:name)으로 이를 재사용할 수 있습니다. 동일한 이름의 임시 칼럼이 이미 존재하면, 매크로는 기존 임시 칼럼을 유지합니다.

transient(:duration) do
  sql("dateDiff('seconds', anyIfMerge(created_event_at), anyIfMerge(finished_event_at))")
end

measurement :duration, :integer, transient(:duration), description: 'Session duration in seconds'

GraphQL에서는 이 집계들이 측정값 이름을 딴 하나의 중첩 그룹 필드로 노출되며, min, max, mean, quantile, sum 하위 필드를 가집니다.

duration {
  min
  max
  mean
  quantile(quantile: 0.95)
  sum
}

orderBy는 점이 포함된 전체 식별자를 허용합니다. 예를 들어 { identifier: "duration.max", direction: DESC }.

요구 사항 및 제한 사항#

  • 엔진 어댑터는 min, max, mean, quantile 메트릭을 지원해야 하며, 합산 가능한 측정값 타입에 대해서는 sum 메트릭도 지원해야 합니다. 이를 지원하지 않는 어댑터(현재는 ActiveRecord 엔진)에서 measurement를 호출하면 ArgumentError가 발생합니다. 실질적으로 이 매크로는 ClickHouse 전용입니다.
  • 이 매크로는 if: 또는 formatter: 옵션을 허용하지 않습니다.
  • 원시 측정값에는 차원이 부여되지 않습니다.
  • metric_range와 metric_exact_match 필터는 점이 포함된 메트릭 식별자를 대상으로 할 수 없습니다.

사용 가능한 구성 요소#

count 메트릭#

countIf()를 사용하여 고유 집계 및 조건부 집계를 지원하며 행을 집계합니다.

Option Type Required Description
name Symbol No count 메트릭의 이름. 기본값: 'total'. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 특정 값을 집계하기 위한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(countIf)
distinct Boolean No 고유 집계를 활성화합니다. 기본값: false
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

mean 메트릭#

avgIf()를 사용하여 조건부 평균을 지원하며 평균값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :mean_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 평균을 낼 값에 대한 커스텀 표현식
if Proc No 조건부 평균을 위한 조건 표현식(avgIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

min 메트릭#

minIf()를 사용하여 조건부 집계를 지원하며 최솟값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :min_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(minIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

max 메트릭#

maxIf()를 사용하여 조건부 집계를 지원하며 최댓값을 계산합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :max_{name}이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
if Proc No 조건부 집계를 위한 조건 표현식(maxIf)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

rate 메트릭#

분자 조건을 충족하는 행과 분모 조건(또는 전체 행)을 충족하는 행 사이의 비율을 계산합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_rate가 됩니다
type Symbol No 데이터 타입. 기본값: :float
numerator_if Proc Yes 분자에 대한 조건(집계할 행)
denominator_if Proc No 분모에 대한 조건. 제공하지 않으면 전체 행 수를 사용합니다
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

quantile 메트릭#

ClickHouse의 quantile() 함수를 사용하여 백분위수를 계산합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자. 식별자는 :{name}_quantile이 됩니다
type Symbol No 데이터 타입. 기본값: :float
expression Proc No 값에 대한 커스텀 표현식
parameters Hash No 파라미터 설정(아래 참조)
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
quantile Float 0.0 - 1.0 0.5 분위수 값(0.5 = 중앙값, 0.9 = p90, 0.99 = p99)

retained_count 메트릭#

groupBitmapState와 arrayIntersect를 사용하여 현재 기간과 이전 기간 모두에 나타나는 값을 집계합니다. 기능 유지율 또는 재방문 사용자 수를 구할 때는 retained_count를 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  retained_count :returning_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users present in both the current and previous period'
end

lagged_count 메트릭#

lagInFrame과 함께 uniqExact를 사용하여 이전 기간의 고유 값 개수를 반환합니다. 보존율(재방문 ÷ 이전 기간)을 계산할 때는 lagged_count와 retained_count를 함께 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식
over Symbol Yes 기간을 정의하는 차원
lag_offset Integer No 되돌아볼 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  lagged_count :previous_period_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Distinct users in the previous period'
end

요청에 over: 외의 차원이 추가로 포함된 경우, 프레임워크는 그 추가 차원으로 lag 윈도우를 분할합니다. 각 조합은 독립적인 시퀀스를 가지므로, 값이 카테고리 간에 섞이지 않습니다. 예를 들어 dimensions: [feature, timestamp]이고 timestamp가 granularity: 'daily'인 date_bucket이며 메트릭이 over: :timestamp를 사용하는 경우, 생성된 SQL에는 OVER (PARTITION BY aeq_feature ORDER BY aeq_timestamp_daily ASC)가 포함됩니다. code_suggestions의 보존율은 chat의 보존율과 섞이지 않습니다.

acquired_count 메트릭#

현재 기간에는 존재하지만 이전 기간에는 없었던 고유 값을 집계합니다. groupArray와 arrayDistinct를 사용하여, 현재 기간의 고유 값 수에서 arrayIntersect 길이를 뺍니다. lagInFrame이 이전 기간을 제공합니다. 신규 사용자 수에는 acquired_count를 사용합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  acquired_count :new_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users active in the current period but not in the previous one'
end

churned_count 메트릭#

이전 기간에는 존재했지만 현재 기간에는 없는 고유 값을 집계합니다. groupArray와 arrayDistinct를 사용하여, 이전 기간의 고유 값 수에서 arrayIntersect 길이를 뺍니다. lagInFrame이 이전 기간을 제공합니다. over:가 참조하는 차원은 쿼리에서 요청되어야 합니다.

Option Type Required Description
name Symbol Yes 식별자 이름. 식별자는 :{name}_count가 됩니다
type Symbol No 데이터 타입. 기본값: :integer
expression Proc No 중복을 제거할 값에 대한 표현식(예: user_id)
over Symbol Yes 기간을 정의하는 차원. 엔진의 차원이어야 합니다
lag_offset Integer No 비교할 기간 수. 기본값: 1
description String No 사람이 읽을 수 있는 설명

예시:

metrics do
  churned_count :churned_users, :integer, -> { sql('user_id') }, over: :timestamp,
    description: 'Users active in the previous period but not in the current one'
end

retained_count, acquired_count, churned_count는 값의 전체 첫 등장이나 마지막 등장이 아니라, 오직 직전 기간과의 상대적 변화만을 측정합니다. 공백 기간 이후 돌아온 사용자는 다시 획득된 것으로 집계되고, 한 기간을 건너뛴 사용자는 그 기간에 이탈한 것으로 집계됩니다.

요청된 범위의 첫 번째 기간에는 직전 기간이 없으므로, 해당 기간의 모든 값은 획득된 것으로 집계되고 이탈 수는 0이 됩니다. 이는 retained_count가 해당 기간에 0을 반환하는 것과 동일합니다. 행이 없는 기간은 결과 집합에서 빠지므로, lag는 마지막으로 행이 있던 기간과 비교됩니다. 이탈은 행이 하나 이상 있는 기간에서만 나타납니다. 행이 0개인 기간은 결과 행 자체를 만들지 않기 때문입니다.

어느 기간에서든 retained_count와 acquired_count의 합은 현재 기간의 고유 값 수와 같고, retained_count와 churned_count의 합은 lagged_count와 같습니다. 따라서 추가 쿼리 없이도 단일 응답에서 이탈률을 도출할 수 있습니다.

column 차원#

칼럼 값으로 결과를 그룹화합니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 데이터 타입(:string, :integer, :datetime 등)
expression Proc No 칼럼 대신 사용할 커스텀 표현식
formatter Proc No 결과에 적용되는 포맷 함수
description String No 사람이 읽을 수 있는 설명
association Boolean No true로 설정하면 _id 접미사 없이 객체로도 차원에 접근할 수 있습니다. 기본값: false.

date_bucket 차원#

ClickHouse의 toStartOfInterval() 함수를 사용하여 시간 간격으로 결과를 그룹화합니다. 파라미터를 지원합니다.

Option Type Required Description
name Symbol Yes 날짜/datetime 칼럼 이름
type Symbol Yes 데이터 타입. :date를 사용합니다. 버킷은 항상 일 단위 경계에서 시작하므로, 모든 세분도에서 값이 날짜로 직렬화됩니다.
expression Proc No 칼럼 대신 사용할 커스텀 표현식
parameters Hash No 파라미터 설정(아래 참조)
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
granularity String daily, weekly, monthly, yearly monthly 그룹화를 위한 시간 간격

tier 차원#

클라이언트가 제공하는 오름차순 정수 임계값을 기준으로, ClickHouse의 multiIf() 함수를 사용하여 숫자 표현식을 서열형 티어(tier_0부터 tier_N까지)로 나눕니다. 파라미터를 지원합니다.

첫 번째 임계값보다 작은 값은 tier_0에 속합니다. 마지막 임계값 이상인 값은 최상위 티어에 속하므로, N개의 임계값은 N+1개의 티어(tier_0부터 tier_N까지)를 만듭니다.

Option Type Required Description
name Symbol Yes 칼럼 이름 또는 식별자
type Symbol Yes 출력 레이블의 데이터 타입(:string 사용)
expression Proc No 칼럼 대신 나눌 숫자 표현식
description String No 사람이 읽을 수 있는 설명

지원되는 파라미터:

Parameter Type Values Default Description
thresholds Array of Integers 최대 9개까지의 순수 오름차순 양의 정수 None(필수) 티어 경계

thresholds 파라미터는 자동으로 선언되며, 이 차원을 사용하는 모든 요청에서 필수입니다. 임계값의 정규화(예: 주 단위 스케일링)는 클라이언트가 처리할 사항입니다.

dimensions do
  tier :user_tier, :string, -> { sql('user_activity.sessions') }, ctes: [:user_activity]
end

exact_match 필터#

정확한 값으로 행을 필터링합니다. 일반 칼럼 또는 merge 칼럼(사전 집계된 데이터)에 대한 필터링을 지원합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입
expression Proc No 칼럼 대신 사용할 커스텀 표현식
merge_column Boolean No true이면 WHERE 대신 HAVING을 사용하여 필터를 적용합니다
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

range 필터#

BETWEEN을 사용하여 값 범위로 행을 필터링합니다. 일반 칼럼 또는 merge 칼럼에 대한 필터링을 지원합니다.

Option Type Required Description
name Symbol Yes 필터링할 칼럼 이름
type Symbol Yes 필터 값의 데이터 타입(:datetime, :integer 등)
expression Proc No 칼럼 대신 사용할 커스텀 표현식
merge_column Boolean No true이면 WHERE 대신 HAVING을 사용하여 필터를 적용합니다
description String No 사람이 읽을 수 있는 설명

metric_exact_match 필터#

집계된 메트릭 값의 정확한 일치로 그룹을 필터링합니다. 집계 이후 HAVING 절로 적용됩니다.

Option Type Required Description
name Symbol Yes 필터링할 메트릭의 식별자. 같은 엔진에 정의된 메트릭과 일치해야 합니다.
type Symbol Yes 필터 값의 데이터 타입
max_size Integer No 필터에서 허용되는 최대 값 수
description String No 사람이 읽을 수 있는 설명

참조된 메트릭은 동일한 Request에서도 요청되어야 합니다. 파라미터화된 메트릭의 경우, 필터의 parameters는 요청된 메트릭 인스턴스의 파라미터와 일치해야 합니다.

예시:

filters do
  metric_exact_match :total_count, :integer
end
Gitlab::Database::Aggregation::Request.new(
  filters: [{ identifier: :total_count, values: [1, 2] }],
  dimensions: [{ identifier: :user_id }],
  metrics: [{ identifier: :total_count }]
)

metric_range 필터#

BETWEEN을 사용하여 집계된 메트릭의 값 범위로 그룹을 필터링합니다. 집계 이후 HAVING 절로 적용됩니다.

Option Type Required Description
name Symbol Yes 필터링할 메트릭의 식별자. 같은 엔진에 정의된 메트릭과 일치해야 합니다.
type Symbol Yes 필터 값의 데이터 타입(:integer, :float 등)
description String No 사람이 읽을 수 있는 설명

참조된 메트릭은 동일한 Request에서도 요청되어야 합니다. 파라미터화된 메트릭의 경우, 필터의 parameters는 요청된 메트릭 인스턴스의 파라미터와 일치해야 올바른 메트릭 인스턴스를 대상으로 합니다.

예시:

filters do
  metric_range :total_count, :integer
  metric_range :duration_quantile, :float
end
Gitlab::Database::Aggregation::Request.new(
  filters: [
    { identifier: :duration_quantile, parameters: { quantile: 0.1 }, values: 200..nil }
  ],
  dimensions: [{ identifier: :user_id }],
  metrics: [{ identifier: :duration_quantile, parameters: { quantile: 0.1 } }]
)

임시 칼럼(Transient columns)#

임시 칼럼은 한 번 정의하고 dimensions, metrics, filters 블록 전반에서 참조할 수 있는 명명된 SQL 표현식 별칭입니다. 최종 쿼리 결과에는 프로젝션되지 않습니다. 복잡한 SQL 표현식의 중복을 없애려면 임시 칼럼을 사용합니다.

임시 칼럼 정의#

클래스 수준에서 이름과 Arel 표현식을 반환하는 블록으로 transient를 호출합니다. 참조하기 전에 먼저 임시 칼럼을 정의합니다.

transient(:duration) do
  sql("dateDiff('seconds', anyIfMerge(created_event_at), anyIfMerge(finished_event_at))")
end

transient(:is_finished) { sql('anyIfMerge(finished_event_at) IS NOT NULL') }

임시 칼럼 참조#

dimensions, metrics, filters 블록 안에서 transient(:name)을 호출하여 저장된 표현식을 삽입합니다. 반환값은 람다 표현식이 허용되는 모든 곳, 즉 위치 인수나 키워드 인수 값으로 전달할 수 있습니다.

metrics do
  mean :duration, :float, transient(:duration),
    description: 'Average session duration in seconds'

  count :finished, if: transient(:is_finished),
    description: 'Number of finished sessions'
end

보조 CTE(베타, 제한된 기능, ClickHouse 전용)#

보조 CTE는 다른 테이블에 대한 키별 요약(예: 사용자별 요약)입니다. 프레임워크는 이를 메인 쿼리에 JOIN으로 결합합니다. 이를 통해 dimensions, filters, metrics가 CTE 집계를 참조할 수 있습니다. 버전 1은 동일 테이블 계약에 따라, 엔진 자체 테이블에 대한 요약만 지원합니다. 지원되는 테이블 엔진은 MergeTree와 ReplacingMergeTree뿐입니다. 이 기능은 ClickHouse 엔진에서만 사용할 수 있습니다.

보조 CTE 정의#

table_name을 설정한 이후 클래스 수준에서 보조 CTE를 선언합니다. supporting_cte :name, join_key: :user_id, join_type: :inner do |qb| ... end를 사용합니다. 블록은 준비된 기본 스코프에 대한 쿼리 빌더를 받습니다. 블록은 오직 qb.select(...)를 통해 집계 프로젝션만 추가해야 합니다. 프레임워크는 join key 칼럼과 GROUP BY join_key를 자동으로 추가합니다.

CTE는 이미 준비된 기본 쿼리의 복사본으로부터 쿼리 시점에 구성됩니다. 엔진의 기본 스코프, ReplacingMergeTree 테이블의 중복 제거 서브쿼리, 그리고 요청의 모든 행 필터를 상속합니다. CTE 자체가 CTE 기반인 필터는 CTE 본문으로 전파되지 않습니다. 이런 필터는 메인 쿼리에만 적용됩니다.

join_type: 파라미터는 :inner(기본값) 또는 :outer를 허용하며, :outer는 LEFT OUTER JOIN으로 렌더링됩니다. 기본값인 inner join에서는, join key가 NULL인 기본 행이 CTE 행과 절대 일치하지 않으므로 제외됩니다. join key가 null을 허용하는 엔진에는 :outer를 사용합니다.

class DuoWorkflowsEngine < Gitlab::Database::Aggregation::ClickHouse::Engine
  self.table_name = 'duo_workflows_workflows_enriched'

  supporting_cte :user_activity_cte, join_key: :user_id do |qb|
    qb.select(
      qb.count.as('workflows'),
      qb.named_func('uniqExact', [qb[:workflow_definition]]).as('flow_types')
    )
  end

  dimensions do
    column :user_tier, :string, -> {
      sql("multiIf(user_activity.workflows >= 5, 'heavy', user_activity_cte.workflows >= 2, 'medium', 'light')")
    }, ctes: [:user_activity_cte]
  end

  filters do
    range :flow_types_used, :integer, -> { sql('user_activity_cte.flow_types') }, ctes: [:user_activity_cte]
  end

  metrics do
    count
    count :users, :integer, -> { sql('user_id') }, distinct: true
  end
end

보조 CTE 참조#

차원, 필터, 메트릭에 ctes: :name 또는 ctes: [:a, :b]를 추가하여 CTE를 선택적으로 사용합니다. 선언되지 않은 CTE 이름을 참조하면 클래스 정의 시점에 ArgumentError가 발생합니다. 참조된 각 CTE는 여러 부분이 참조하더라도 쿼리당 정확히 한 번만 구성되고 JOIN됩니다.

표현식은 스코프에 CTE가 하나뿐이더라도, user_activity.workflows처럼 CTE 이름으로 CTE 칼럼을 한정해야 합니다. CTE를 기반으로 하는 필터는 JOIN된 요약 칼럼에 대한 일반 WHERE 절이 되며, HAVING 절이 되지 않습니다. 이 덕분에 "사용자별 조건과 일치하는 사용자 수"를 단일 숫자 쿼리로 만들 수 있습니다.

다음 요청은 단일 숫자, 즉 두 가지 이상의 플로 유형을 사용한 사용자 수를 반환합니다.

Gitlab::Database::Aggregation::Request.new(
  filters: [{ identifier: :flow_types_used, values: 2..nil }],
  metrics: [{ identifier: :users_count }]
)

쿼리 비용#

참조된 각 CTE는 기본 스코프에 대한 추가 스캔 한 번과, 인메모리 해시 JOIN을 더합니다. 단일 칼럼 CTE 여러 개보다는, 집계 칼럼 여러 개를 가진 CTE 하나를 선호합니다.

프레임워크 사용#

집계 요청 생성#

request = Gitlab::Database::Aggregation::Request.new(
  filters: [
    { identifier: :project_id, values: [1, 2, 3] },
    { identifier: :state, values: ['opened'] }
  ],
  dimensions: [
    { identifier: :author_id },
    { identifier: :created_at, parameters: { granularity: 'monthly' } },
    { identifier: :created_at, parameters: { granularity: 'weekly' } },
  ],
  metrics: [
    { identifier: :total_count },
    { identifier: :mean_weight }
  ],
  order: [
    { identifier: :total_count, direction: :desc } # order identifier must reference dimension or metric.
  ]
)

엔진으로 요청 실행#

engine = IssueAggregationEngine.new(context: { scope: Issue.all })
response = engine.execute(request)

if response.success?
  puts "Success: #{response.payload[:data].to_a.inspect}"
else
  puts "Errors: #{response.errors}"
end
  • 엔진에는 기본 스코프가 제공되어야 합니다. 사용 사례에 따라 현재 프로젝트, 네임스페이스, 사용자 등으로 이미 사전 필터링된 스코프를 제공할 수 있습니다.
  • 모든 요청 필터는 제공된 기본 스코프에 적용됩니다.

아키텍처 개요#

프레임워크는 다음과 같은 주요 구성 요소로 이루어져 있습니다:

  • Engine: 특정 데이터 소스에 대해 사용 가능한 메트릭, 차원, 필터를 정의하는 핵심 클래스
  • Request: 선택된 메트릭, 차원, 필터, 정렬을 포함하는 쿼리 요청을 나타냅니다
  • QueryPlan: 요청을 검증하고 실행 가능한 쿼리 부분으로 변환합니다
  • AggregationResult: 쿼리 실행과 결과 포맷을 처리합니다
┌────────────────────────────────────────────────────┐
│                        Request                     │
│  (metrics, dimensions, filters, order)             │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                        QueryPlan                   │
│  (validates request, builds plan parts)            │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                         Engine                     │
│  (executes query plan, returns AggregationResult)  │
└────────────────────────────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────┐
│                   AggregationResult                │
│ implements Enumerable to access formatted results  │
└────────────────────────────────────────────────────┘

유효성 검사#

프레임워크는 실행 전에 요청을 검증합니다:

  • 최소 하나의 메트릭이 필요합니다
  • 참조된 모든 식별자는 엔진 정의에 존재해야 합니다
  • 파라미터는 선언된 유효성 검사 조건을 충족해야 합니다. 예를 들어 granularity: { in: %i[daily weekly monthly], type: :string }는 granularity 값이 제공된 3개의 문자열 중 하나여야 함을 요구합니다.

GraphQL 통합#

Gitlab::Database::Aggregation::Graphql::Mounter 모듈을 사용하여 GraphQL API에서 집계 엔진을 노출합니다.

GraphQL 통합은 다음을 자동으로 생성합니다:

  • Query field: 마운트된 엔진에 대한 쿼리 필드
  • Filter arguments: 엔진 필터 정의를 기반으로 한 필터 인수
  • Order argument: 엔진 차원 및 메트릭 정의를 기반으로 한 정렬 인수. 스네이크 케이스로 변환된 차원 및 메트릭 식별자를 정렬 식별자로 사용할 수 있습니다
  • Response types: 차원과 메트릭을 필드로 포함하는 응답 타입. 점이 포함된 식별자를 가진 메트릭은 공유 그룹 필드 아래에 중첩됩니다
  • Parameterized fields: 파라미터가 있는 차원 및 메트릭을 위한 파라미터화된 필드
  • Pagination: 집계 결과는 OFFSET 페이지네이션을 사용하여 자동으로 페이지 처리됩니다

엔진 마운트#

GraphQL 타입에서 mount_aggregation_engine 메서드를 사용하여 집계 엔진을 노출합니다:

module Types
  class ProjectType < BaseObject
    extend Gitlab::Database::Aggregation::Graphql::Mounter

    mount_aggregation_engine(
      IssueAggregationEngine,
      field_name: 'issue_analytics',
      description: 'Issue analytics aggregation'
    ) do
      # Define base aggregation scope. Build your own scope or inherit one from parent object.
      def aggregation_scope
        object.issues
      end
    end
  end
end
Note

모든 필터, 메트릭, 차원은 자동으로 노출됩니다.

Mounter 옵션#

Option Type Description
field_name String/Symbol GraphQL 필드 이름. 기본값: :aggregation
types_prefix String/Symbol *AggregationResponse와 같은 모든 자식 타입의 접두사. 기본값: field_name
description String GraphQL 필드에 대한 설명
authorize Symbol 필드에 접근하기 위해 필요한 권한(예: :read_project). GraphQL 필드 정의에 직접 전달됩니다

인가#

authorize 옵션을 사용하여 필드에 대한 접근을 제한합니다:

mount_aggregation_engine(
  IssueAggregationEngine,
  field_name: 'issue_analytics',
  description: 'Issue analytics aggregation',
  authorize: :read_project
) do
  # authorize :read_project - this also supported.
  def aggregation_scope
    object.issues
  end
end

authorize를 지정하지 않으면 인가를 수동으로 처리해야 합니다.

부분 단위 인가#

개별 메트릭, 차원, 필터는 위에서 설명한 필드 수준 authorize와는 별도로, 추가적인 가시성 검사를 요구하는 자체 authorize: 옵션을 선언할 수 있습니다:

metrics do
  count :total_count, :integer
  count :owner_count, :integer, authorize: :owner_access
end

dimensions do
  column :status, :string
  column :internal_flag, :string, authorize: ->(user, resources) { resources.all? { |r| r.member?(user) } }
end

authorize:는 능력(ability) 심볼을 받을 수 있으며, 이 경우 엔진 컨텍스트의 authorization_resources에 있는 모든 리소스에 대해 Ability.allowed?(user, ability, resource)로 검사됩니다. 또는 (user, resources)로 한 번 호출되어 불리언을 반환하고 모든 리소스의 인가를 스스로 책임지는 콜러블을 받을 수도 있습니다. measurement 매크로도 authorize:를 받아, 확장된 모든 점 포함 메트릭(.min, .max, .mean, .quantile, .sum)에 이를 전파합니다.

사용자가 특정 부분에 대해 인가되지 않은 경우:

  • 보호된 메트릭은 요청에서 조용히 제외되고, 해당 필드는 null을 반환합니다. 응답 형태는 변경되지 않습니다.
  • 보호된 차원, 필터, 정렬은 명확한 오류와 함께 요청 검증에 실패합니다.

GraphQL 쿼리 예시#

생성된 GraphQL 서브트리는 두 단계 구조를 사용합니다:

  • 외부 필드(issueAnalytics)는 차원 및 비메트릭 필터 인수를 허용합니다.
  • 내부 aggregated 필드는 메트릭 필터, 정렬, 페이지네이션 인수를 허용하고, 페이지 처리된 커넥션을 반환합니다.
query IssueAnalytics($projectId: ID!) {
  project(fullPath: $projectId) {
    issueAnalytics(
      state: ["opened", "closed"]
      createdAtFrom: "2024-01-01"
      createdAtTo: "2024-12-31"
    ) {
      aggregated(
        totalCountFrom: 5
        orderBy: [{ identifier: "totalCount", direction: DESC }]
        first: 10
      ) {
        nodes {
          dimensions {
            createdAt(granularity: "monthly")
          }
          totalCount
          meanWeight
          highQuantile: durationQuantile(0.9)
          medianQuantile: durationQuantile(0.5)
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
}

필터 배치#

필터 인수는 필터가 적용되는 시점을 기준으로 두 레벨에 분산됩니다:

  • 비메트릭 필터(exact_match 또는 range로 정의된 것)는 외부 필드(예: issueAnalytics)에 나타납니다.
  • 메트릭 필터(metric_exact_match 또는 metric_range로 정의된 것)는 내부 aggregated 필드에 나타납니다.

점이 포함된 메트릭 식별자#

동일한 기본 값의 여러 집계를 하나의 GraphQL 필드 아래에 묶으려면, 점이 포함된 이름으로 메트릭을 선언합니다. 이 이름은 식별자로 그대로 사용됩니다(일반적인 :{name}_count 형식의 파생은 건너뜁니다):

metrics do
  mean :"duration.mean", :float, ->(_params) { Arel.sql("dateDiff('seconds', created_at, finished_at)") }
  quantile :"duration.quantile", :float, ->(_params) { Arel.sql("dateDiff('seconds', created_at, finished_at)") },
    parameters: { quantile: { type: :float } }
end

접두사를 공유하는 모든 메트릭은 중첩된 dimensions 하위 객체와 유사하게, 두 번째 세그먼트마다 하위 필드를 가진 하나의 객체 필드가 됩니다. 파라미터가 있는 메트릭은 하위 필드에 인수를 유지합니다:

nodes {
  duration {
    mean
    quantile(quantile: 0.9)
  }
}

명시적 표현식이 없는 점 포함 메트릭은 첫 번째 세그먼트 이름의 데이터베이스 칼럼을 읽는 것으로 대체됩니다(예: duration.max는 duration 칼럼을 읽습니다). count 메트릭은 이름을 칼럼으로 참조하지 않습니다.

정의 시점에 적용되는 규칙:

  • 점으로 구분된 세그먼트가 정확히 두 개여야 하며, 각 세그먼트는 [a-z][a-z0-9_]*와 일치해야 합니다. 메트릭에만 적용되며, 차원과 필터는 점이 포함된 이름을 거부합니다.
  • 접두사는 플랫(flat) 메트릭 식별자와 충돌해서는 안 되며, dimensions는 예약어입니다.
  • 점 정규화 이후에도 식별자는 고유해야 합니다. 인스턴스 키(SQL 별칭 및 결과 행 키)는 .을 __로 치환하므로, duration.max는 duration__max 메트릭과 충돌합니다.

orderBy는 점이 포함된 전체 식별자를 허용합니다({ identifier: "duration.max", direction: DESC }). 메트릭 필터는 점이 포함된 메트릭을 대상으로 할 수 없습니다.

커스텀 요청 유효성 검사#

GraphQL 스키마를 유지하면서 특정 집계 요청을 거부하는 커스텀 유효성 검사 로직을 추가합니다. 특정 요청에 커스텀 런타임 제약을 적용해야 할 때 유용합니다.

GraphQL::ExecutionError를 발생시켜 커스텀 에러 메시지와 함께 요청을 거부할 수 있습니다.

커스텀 유효성 검사를 추가하려면 마운팅 블록에서 validate_request! 메서드를 오버라이드합니다:

module Types
  class ProjectType < BaseObject
    extend Gitlab::Database::Aggregation::Graphql::Mounter

    mount_aggregation_engine(IssueAggregationEngine) do
      # Other configuration options...
      # Custom validation logic
      def validate_request!(engine_request)
        if engine_request.dimensions.empty?
          raise GraphQL::ExecutionError, 'At least one dimension must be specified'
        end
      end
    end
  end
end

validate_request! 메서드는 dimensions, metrics, filters, order 사양을 포함하는 Gitlab::Database::Aggregation::Request 객체를 받습니다.

ActiveRecord 연관을 위한 차원#

차원은 association: true 옵션을 사용하여 연관으로 표시할 수 있습니다. 이렇게 하면 차원이 GraphQL에 노출되는 방식이 변경되어, ID만 노출하는 대신 연관된 모델을 자동으로 리졸브합니다.

연관 차원 정의#

집계 엔진에서 association: true를 사용하여 차원을 선언합니다:

class AgentPlatformSessions < Gitlab::Database::Aggregation::ClickHouse::Engine
  dimensions do
    column :flow_type, :string, description: 'Type of session'
    column :user_id, :integer, description: 'Session owner', association: true
  end
end

GraphQL 스키마에 미치는 영향#

차원이 연관으로 표시되면, 원시 *_id 필드 대신 객체가 노출됩니다. 위의 차원은 GraphQL에서 ID로 배치 로딩하여 field :user, Types::UserType, ...으로 변환됩니다. 연관 이름에서 _id 접미사를 제거하여 연관 ID로 차원을 정렬할 수 있습니다(예: orderBy: [{ identifier: "user", direction: DESC }]).

Note

연관 GraphQL 타입에 대한 모든 적절한 인가 검사를 보장해야 합니다(예: authorize :read_user).

커스텀 연관 설정#

기본적으로 연관 모델과 GraphQL 타입은 차원 이름으로부터 추론됩니다:

  • Model: user_id → User
  • GraphQL type: User → Types::UserType

association 옵션에 해시를 전달하여 이 동작을 커스터마이징할 수 있습니다:

dimensions do
  column :author_id, :integer,
    description: 'Issue author',
    association: { model: User }
    # or model and GraphQL type
    # association: { model: User, graphql_type: Types::CurrentUserType }
end

GraphQL 쿼리 예시#

다음은 연관 없이 사용하는 쿼리 예시입니다:

query {
  project(fullPath: "gitlab-org/gitlab") {
    aiUsage {
      agentPlatformSessions {
        aggregated {
          nodes {
            dimensions {
              userId  # Returns: 123 (integer)
            }
          }
        }
      }
    }
  }
}

다음은 연관을 사용하는 쿼리 예시입니다:

query {
  project(fullPath: "gitlab-org/gitlab") {
    aiUsage {
      agentPlatformSessions(
        userId: [1, 2]  # Filter still uses original dimension identifier
      ) {
        aggregated(
          orderBy: [{ identifier: "user", direction: DESC }]  # Order uses association name
        ) {
          nodes {
            dimensions {
              user {  # Returns: full User object
                id
                username
                name
              }
            }
          }
        }
      }
    }
  }
}

관련 문서#