diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue
index 72192b005af0b539e01e28cc8eb1cf885570a2e2..c6262bd4802815c44b9dfb365eeeff0482bc4e4b 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/data_table.vue
@@ -23,6 +23,7 @@ export default {
ChangePercentageIndicator: () => import('./change_percentage_indicator.vue'),
MetricLabel: () => import('./metric_label.vue'),
TrendLine: () => import('./trend_line.vue'),
+ UserLink: () => import('./user_link.vue'),
},
props: {
data: {
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..d65e7b81bf06889456ff12898067095f3a35af6f
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.stories.js
@@ -0,0 +1,66 @@
+import DataTable from './data_table.vue';
+import UserLink from './user_link.vue';
+
+export default {
+ component: UserLink,
+ title: 'ee/analytics/analytics_dashboards/components/visualizations/data_table/user_link',
+};
+
+const firstChild = {
+ user: {
+ name: 'Ayanami Rei',
+ avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ username: 'ramiel',
+ webUrl: 'https://gitlab.com/fakeuser',
+ },
+};
+
+const nodes = [
+ firstChild,
+ {
+ user: {
+ name: 'Shikinami-Langley Asuka',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon',
+ username: 'sachiel',
+ webUrl: 'https://gitlab.com/fakeuser',
+ },
+ },
+ {
+ user: {
+ name: 'Makinami Mari',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon',
+ username: 'leliel',
+ webUrl: 'https://gitlab.com/fakeuser',
+ },
+ },
+];
+
+const Template = (args, { argTypes }) => ({
+ components: { UserLink },
+ props: Object.keys(argTypes),
+ template: ``,
+});
+
+const TableTemplate = (args, { argTypes }) => ({
+ components: { DataTable, UserLink },
+ props: Object.keys(argTypes),
+ template: ``,
+});
+
+export const Default = Template.bind({});
+Default.args = { ...firstChild.user };
+
+export const InTable = TableTemplate.bind({});
+InTable.args = {
+ data: {
+ nodes,
+ },
+ options: {
+ fields: [
+ { key: 'user.name', label: 'Name' },
+ { key: 'user', label: 'User', component: 'UserLink' },
+ ],
+ },
+};
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9cfb59e934fdeeb978ccbdd9a299b0f4b075454e
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js
index 287ea8c16509595882dcd21362fd87867b4b3475..735ab7fb17cf50fd07652169f77b67406b569de3 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/index.js
@@ -49,4 +49,5 @@ export default {
code_suggestions_acceptance_by_language: () =>
import('./code_suggestions_acceptance_by_language'),
code_generation_volume_over_time: () => import('./code_generation_volume_over_time'),
+ user_ai_usage_data: () => import('./user_ai_usage_data'),
};
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..2632104251842f9a78ae164c8f611aec9cc67b46
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/user_ai_usage_data.js
@@ -0,0 +1,29 @@
+import UserAiUserMetricsQuery from 'ee/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql';
+import { startOfTomorrow } from 'ee/analytics/dora/components/static_data/shared';
+import { getStartDate } from 'ee/analytics/analytics_dashboards/components/filters/utils';
+import { DATE_RANGE_OPTION_LAST_30_DAYS } from 'ee/analytics/analytics_dashboards/components/filters/constants';
+import { extractQueryResponseFromNamespace } from '~/analytics/shared/utils';
+import { defaultClient } from '../graphql/client';
+
+export default async function fetch({
+ namespace: fullPath,
+ query: { dateRange = DATE_RANGE_OPTION_LAST_30_DAYS },
+ queryOverrides: { pagination = { first: 20 } } = {},
+}) {
+ const startDate = getStartDate(dateRange);
+
+ const response = await defaultClient.query({
+ query: UserAiUserMetricsQuery,
+ variables: {
+ fullPath,
+ startDate,
+ endDate: startOfTomorrow,
+ first: pagination.first,
+ last: pagination.last,
+ before: pagination.startCursor,
+ after: pagination.endCursor,
+ },
+ });
+
+ return extractQueryResponseFromNamespace({ result: response, resultKey: 'aiUserMetrics' });
+}
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..2ba2c7b951f6fda6160b4fbdd1b207b9ef59b4d7
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/fragments/ai_user_metric_item.fragment.graphql
@@ -0,0 +1,12 @@
+fragment AiUserMetricItem on AiUserMetrics {
+ user {
+ id
+ name
+ avatarUrl
+ username
+ webUrl
+ lastDuoActivityOn
+ }
+ codeSuggestionsAcceptedCount
+ duoChatInteractionsCount
+}
diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..7ac4f3621686ffd975514c7443c726f4276f01c6
--- /dev/null
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/graphql/queries/get_user_ai_user_metrics.query.graphql
@@ -0,0 +1,49 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "../fragments/ai_user_metric_item.fragment.graphql"
+
+query getUserAiUserMetrics(
+ $fullPath: ID!
+ $startDate: Date!
+ $endDate: Date!
+ $before: String
+ $after: String
+ $last: Int
+ $first: Int
+) {
+ group(fullPath: $fullPath) {
+ id
+ aiUserMetrics(
+ startDate: $startDate
+ endDate: $endDate
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
+ nodes {
+ ...AiUserMetricItem
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ project(fullPath: $fullPath) {
+ id
+ aiUserMetrics(
+ startDate: $startDate
+ endDate: $endDate
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
+ nodes {
+ ...AiUserMetricItem
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/ee/app/models/analytics/dashboards/visualization.rb b/ee/app/models/analytics/dashboards/visualization.rb
index 5396ce0077357ea41efa832e2d5bc6395480ed92..d9cc40491d3cc064abe78ccc301292696a0b4ed4 100644
--- a/ee/app/models/analytics/dashboards/visualization.rb
+++ b/ee/app/models/analytics/dashboards/visualization.rb
@@ -70,6 +70,7 @@ class Visualization
pipeline_metrics_table
code_suggestions_acceptance_rate_by_language_chart
code_generation_volume_trends_chart
+ user_metrics_table
].freeze
CONTRIBUTIONS_DASHBOARD_PATH = 'ee/lib/gitlab/analytics/contributions_dashboard/visualizations'
diff --git a/ee/app/validators/json_schemas/analytics_visualization.json b/ee/app/validators/json_schemas/analytics_visualization.json
index 25b56765a265e0ce57dd768616e93111f9272d3d..4d9d3e803738eb7ed04f36850f0e68aefda6c414 100644
--- a/ee/app/validators/json_schemas/analytics_visualization.json
+++ b/ee/app/validators/json_schemas/analytics_visualization.json
@@ -79,7 +79,8 @@
"merge_request_counts",
"mean_time_to_merge",
"code_suggestions_acceptance_by_language",
- "code_generation_volume_over_time"
+ "code_generation_volume_over_time",
+ "user_ai_usage_data"
]
},
"query": {
@@ -146,7 +147,8 @@
"MergeRequestLink",
"ChangePercentageIndicator",
"MetricLabel",
- "TrendLine"
+ "TrendLine",
+ "UserLink"
]
}
}
diff --git a/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml b/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml
index 33dd51c5d9b4e790441a4dfebe1413a4a71e8949..a06a454f69e096b0a7f8b68a62e14dce34af9fc5 100644
--- a/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml
+++ b/ee/lib/gitlab/analytics/ai_impact_dashboard/dashboard.yaml
@@ -74,3 +74,10 @@ panels:
width: 12
height: 4
options: {}
+ - title: 'Code Suggestions accepted by user'
+ visualization: user_metrics_table
+ gridAttributes:
+ yPos: 20
+ xPos: 0
+ width: 12
+ height: 12
diff --git a/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml b/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..19852c82302b7f82d90c6da4a49ec6f60f567911
--- /dev/null
+++ b/ee/lib/gitlab/analytics/ai_impact_dashboard/visualizations/user_metrics_table.yaml
@@ -0,0 +1,16 @@
+---
+version: 1
+type: DataTable
+data:
+ type: user_ai_usage_data
+ query: {}
+options:
+ fields:
+ - key: 'user'
+ component: 'UserLink'
+ - key: 'user.lastDuoActivityOn'
+ label: 'Last duo activity'
+ - key: 'codeSuggestionsAcceptedCount'
+ label: 'Code suggestions accepted'
+ thClass: "gl-text-right"
+ tdClass: "gl-text-right"
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap
new file mode 100644
index 0000000000000000000000000000000000000000..d373444b5a3635f940890486b679d37f67d12528
--- /dev/null
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/__snapshots__/user_ai_usage_data_spec.js.snap
@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`User ai usage data source with data available returns data and pagination information 1`] = `
+{
+ "nodes": [
+ {
+ "__typename": "AiUserMetrics",
+ "codeSuggestionsAcceptedCount": 0,
+ "duoChatInteractionsCount": 0,
+ "user": {
+ "__typename": "AddOnUser",
+ "avatarUrl": "https://www.gravatar.com/avatar/3dd3516a13d37b25b66c7915ab9bedfe7b20ffaf32da3f50c3cb5ee9f3f8aba6?s=80&d=identicon",
+ "id": "gid://gitlab/User/106",
+ "lastDuoActivityOn": "2025-10-01",
+ "name": "P Dawn Turner",
+ "username": "p-user-dawn-turner-e5c194af9850",
+ "webUrl": "http://gdk.test:3001/p-user-dawn-turner-e5c194af9850",
+ },
+ },
+ {
+ "__typename": "AiUserMetrics",
+ "codeSuggestionsAcceptedCount": 0,
+ "duoChatInteractionsCount": 0,
+ "user": {
+ "__typename": "AddOnUser",
+ "avatarUrl": "https://www.gravatar.com/avatar/142fcdcab9580b9edd2b623335dde4e6559a628daea89773acf60ba419da66bb?s=80&d=identicon",
+ "id": "gid://gitlab/User/105",
+ "lastDuoActivityOn": "2025-10-04",
+ "name": "P Homer Hodkiewicz",
+ "username": "p-user-homer-hodkiewicz-e5c194af9850",
+ "webUrl": "http://gdk.test:3001/p-user-homer-hodkiewicz-e5c194af9850",
+ },
+ },
+ ],
+ "pageInfo": {
+ "__typename": "PageInfo",
+ "endCursor": "this-is-an-end-cursor",
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "this-is-a-start-cursor",
+ },
+}
+`;
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4374374a656a786bd5dd53b4e2375f89fd9880a9
--- /dev/null
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/user_ai_usage_data_spec.js
@@ -0,0 +1,163 @@
+import fetch from 'ee/analytics/analytics_dashboards/data_sources/user_ai_usage_data';
+import { defaultClient } from 'ee/analytics/analytics_dashboards/graphql/client';
+
+import {
+ DATE_RANGE_OPTION_LAST_30_DAYS,
+ DATE_RANGE_OPTION_LAST_60_DAYS,
+} from 'ee/analytics/analytics_dashboards/components/filters/constants';
+
+const mockPageInfo = {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: 'this-is-a-start-cursor',
+ endCursor: 'this-is-an-end-cursor',
+ __typename: 'PageInfo',
+};
+
+const mockUserAiMetrics = [
+ {
+ user: {
+ id: 'gid://gitlab/User/106',
+ name: 'P Dawn Turner',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/3dd3516a13d37b25b66c7915ab9bedfe7b20ffaf32da3f50c3cb5ee9f3f8aba6?s=80\u0026d=identicon',
+ username: 'p-user-dawn-turner-e5c194af9850',
+ webUrl: 'http://gdk.test:3001/p-user-dawn-turner-e5c194af9850',
+ lastDuoActivityOn: '2025-10-01',
+ __typename: 'AddOnUser',
+ },
+ codeSuggestionsAcceptedCount: 0,
+ duoChatInteractionsCount: 0,
+ __typename: 'AiUserMetrics',
+ },
+ {
+ user: {
+ id: 'gid://gitlab/User/105',
+ name: 'P Homer Hodkiewicz',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/142fcdcab9580b9edd2b623335dde4e6559a628daea89773acf60ba419da66bb?s=80\u0026d=identicon',
+ username: 'p-user-homer-hodkiewicz-e5c194af9850',
+ webUrl: 'http://gdk.test:3001/p-user-homer-hodkiewicz-e5c194af9850',
+ lastDuoActivityOn: '2025-10-04',
+ __typename: 'AddOnUser',
+ },
+ codeSuggestionsAcceptedCount: 0,
+ duoChatInteractionsCount: 0,
+ __typename: 'AiUserMetrics',
+ },
+];
+
+const mockUserAiUsageDataResponseData = {
+ aiUserMetrics: {
+ nodes: mockUserAiMetrics,
+ pageInfo: mockPageInfo,
+ },
+};
+
+const mockResolvedQuery = ({ aiUserMetrics = [] } = {}) =>
+ jest.spyOn(defaultClient, 'query').mockResolvedValue({ data: { project: { aiUserMetrics } } });
+
+const expectQueryWithVariables = (variables) =>
+ expect(defaultClient.query).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: expect.objectContaining({
+ ...variables,
+ }),
+ }),
+ );
+
+describe('User ai usage data source', () => {
+ let res;
+
+ const namespace = 'test-namespace';
+ const defaultQueryParams = {
+ dateRange: DATE_RANGE_OPTION_LAST_30_DAYS,
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('can override default query parameters', async () => {
+ mockResolvedQuery();
+
+ res = await fetch({
+ namespace,
+ query: {
+ ...defaultQueryParams,
+ dateRange: DATE_RANGE_OPTION_LAST_60_DAYS,
+ },
+ });
+
+ expectQueryWithVariables({
+ fullPath: namespace,
+ startDate: new Date('2020-05-08'),
+ endDate: new Date('2020-07-07'),
+ });
+
+ expect(defaultClient.query).toHaveBeenCalledTimes(1);
+ });
+
+ it('can override default pagination', async () => {
+ mockResolvedQuery();
+
+ res = await fetch({
+ namespace,
+ query: {
+ ...defaultQueryParams,
+ },
+ queryOverrides: {
+ pagination: { startCursor: 'start' },
+ },
+ });
+
+ expectQueryWithVariables({
+ fullPath: namespace,
+ startDate: new Date('2020-06-07'),
+ endDate: new Date('2020-07-07'),
+ before: 'start',
+ });
+
+ expect(defaultClient.query).toHaveBeenCalledTimes(1);
+ });
+
+ describe('with data available', () => {
+ beforeEach(async () => {
+ mockResolvedQuery(mockUserAiUsageDataResponseData);
+
+ res = await fetch({
+ namespace,
+ query: defaultQueryParams,
+ });
+ });
+
+ it('sets the correct query parameters', () => {
+ expectQueryWithVariables({
+ fullPath: namespace,
+ startDate: new Date('2020-06-07'),
+ endDate: new Date('2020-07-07'),
+ });
+
+ expect(defaultClient.query).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns data and pagination information', () => {
+ expect(res).toMatchSnapshot();
+ });
+ });
+
+ describe('with no data available', () => {
+ beforeEach(async () => {
+ mockResolvedQuery();
+
+ res = await fetch({
+ namespace,
+ query: defaultQueryParams,
+ });
+ });
+
+ it('returns an empty array', () => {
+ expect(res).toHaveLength(0);
+ });
+ });
+});
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..57d97f44ee11ad93978e6e9000141de5c9168a58
--- /dev/null
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/visualizations/data_table/user_link_spec.js
@@ -0,0 +1,42 @@
+import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import UserLink from 'ee/analytics/analytics_dashboards/components/visualizations/data_table/user_link.vue';
+
+const mockData = {
+ name: 'Shikinami-Langley Asuka',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/c4ab964b90c3049c47882b319d3c5cc0?s=80\u0026d=identicon',
+ username: 'sachiel',
+ webUrl: 'https://gitlab.com/fakeuser',
+};
+
+describe('UserLink', () => {
+ let wrapper;
+
+ const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
+ const findLabeledAvatar = () => wrapper.findComponent(GlAvatarLabeled);
+
+ describe('default', () => {
+ beforeEach(() => {
+ wrapper = shallowMountExtended(UserLink, {
+ propsData: { ...mockData },
+ });
+ });
+
+ it('renders the avatar link', () => {
+ expect(findAvatarLink().attributes()).toEqual({
+ href: 'https://gitlab.com/fakeuser',
+ target: 'blank',
+ });
+ });
+
+ it('renders the labeled avatar', () => {
+ expect(findLabeledAvatar().attributes()).toMatchObject({
+ label: 'Shikinami-Langley Asuka',
+ sublabel: '@sachiel',
+ alt: 'sachiel avatar',
+ size: '32',
+ });
+ });
+ });
+});
diff --git a/ee/spec/models/analytics/dashboards/visualization_spec.rb b/ee/spec/models/analytics/dashboards/visualization_spec.rb
index 8e308e63f54d638414994f17de6cead32f9f246e..e3c13c9f41488145d3f1b6c93311c70f99405173 100644
--- a/ee/spec/models/analytics/dashboards/visualization_spec.rb
+++ b/ee/spec/models/analytics/dashboards/visualization_spec.rb
@@ -51,6 +51,7 @@
pipeline_metrics_table
code_suggestions_acceptance_rate_by_language_chart
code_generation_volume_trends_chart
+ user_metrics_table
]
end
diff --git a/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb b/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb
index 66a53310045f3efc9bbb4b79f458718d56a4c3ff..da3c9d403f7d9b4a28dabf51f37b08075d919f30 100644
--- a/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb
+++ b/ee/spec/requests/api/graphql/analytics/dashboards/visualizations_spec.rb
@@ -74,6 +74,7 @@
6 | 'AiImpactTable' | 'Pipeline metrics for the %{namespaceName} %{namespaceType}'
7 | 'BarChart' | 'Code suggestions acceptance rate by language (Last 30 days)'
8 | 'AreaChart' | 'Code generation volume trends (Last 180 days)'
+ 9 | 'DataTable' | 'Code Suggestions accepted by user'
end
with_them do