From 8831e74fa357f4aa138ba70a5053a5903a3ad8cb Mon Sep 17 00:00:00 2001
From: Daniele Rossetti <drossetti@gitlab.com>
Date: Tue, 1 Oct 2024 06:20:54 +0000
Subject: [PATCH] Fix broken dependency

(Run yarn add modern-screenshot again)
---
 app/assets/javascripts/api/projects_api.js    |  28 ++++
 .../javascripts/lib/utils/image_utils.js      |   5 +
 .../metrics/details/metrics_details.vue       |  72 ++++++++--
 .../metrics/details/metrics_heatmap.vue       |   2 +-
 .../metrics/details/metrics_line_chart.vue    |   6 +-
 .../metrics/details/metrics_snapshot.js       |  20 +++
 .../javascripts/metrics/details/utils.js      |  21 ++-
 .../javascripts/metrics/details_index.vue     |   5 +
 .../observability/metrics_issues_helper.rb    |  12 +-
 .../helpers/projects/observability_helper.rb  |   3 +-
 .../metrics/details/metrics_details_spec.js   | 125 ++++++++++++++++--
 .../metrics/details/metrics_snapshot_spec.js  |  39 ++++++
 .../frontend/metrics/details/utils_spec.js    |  55 +++++++-
 .../frontend/metrics/details_index_spec.js    |   2 +
 .../metrics_issues_helper_spec.rb             |  16 +++
 .../projects/observability_helper_spec.rb     |  12 +-
 .../requests/projects/logs_controller_spec.rb |   1 +
 .../projects/metrics_controller_spec.rb       |   4 +-
 .../projects/tracing_controller_spec.rb       |   4 +-
 locale/gitlab.pot                             |   3 +
 package.json                                  |   1 +
 spec/frontend/api/projects_api_spec.js        |  85 ++++++++++++
 yarn.lock                                     |  36 ++---
 23 files changed, 494 insertions(+), 63 deletions(-)
 create mode 100644 ee/app/assets/javascripts/metrics/details/metrics_snapshot.js
 create mode 100644 ee/spec/frontend/metrics/details/metrics_snapshot_spec.js

diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index e85553cc2923d..558520faf94a4 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -10,6 +10,7 @@ const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_m
 const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size';
 const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations';
 const PROJECT_SHARE_LOCATIONS_PATH = 'api/:version/projects/:id/share_locations';
+const PROJECT_UPLOADS_PATH = '/api/:version/projects/:id/uploads';
 
 export function getProjects(query, options, callback = () => {}) {
   const url = buildApiUrl(PROJECTS_PATH);
@@ -91,3 +92,30 @@ export const getProjectShareLocations = (projectId, params = {}, axiosOptions =
 
   return axios.get(url, { params: { ...defaultParams, ...params }, ...axiosOptions });
 };
+
+/**
+ * Uploads an image to a project and returns the share URL.
+ *
+ * @param {Object} options - The options for uploading the image.
+ * @param {string} options.filename - The name of the file to be uploaded.
+ * @param {Blob} options.blobData - The blob data of the image to be uploaded.
+ * @param {string} options.projectId - The ID of the project.
+ * @returns {Promise<string>} The share URL of the uploaded image
+ */
+
+export async function uploadImageToProject({ filename, blobData, projectId }) {
+  const url = buildApiUrl(PROJECT_UPLOADS_PATH).replace(':id', projectId);
+
+  const formData = new FormData();
+  formData.append('file', blobData, filename);
+
+  const result = await axios.post(url, formData, {
+    headers: { 'Content-Type': 'multipart/form-data' },
+  });
+
+  if (!result.data?.full_path) {
+    return Promise.reject(new Error(`Image failed to upload`));
+  }
+
+  return new URL(result.data.full_path, document.baseURI).href;
+}
diff --git a/app/assets/javascripts/lib/utils/image_utils.js b/app/assets/javascripts/lib/utils/image_utils.js
index 53906a5ab8744..2a240a39945f1 100644
--- a/app/assets/javascripts/lib/utils/image_utils.js
+++ b/app/assets/javascripts/lib/utils/image_utils.js
@@ -1,3 +1,4 @@
+import { domToBlob } from 'modern-screenshot';
 import vector from './vector';
 import { readFileAsDataURL } from './file_utility';
 
@@ -64,3 +65,7 @@ export const getRetinaDimensions = async (pngFile) => {
     return null;
   }
 };
+
+export function domElementToBlob(domElement) {
+  return domToBlob(domElement);
+}
diff --git a/ee/app/assets/javascripts/metrics/details/metrics_details.vue b/ee/app/assets/javascripts/metrics/details/metrics_details.vue
index c747da42fd8ba..200a708aaf632 100644
--- a/ee/app/assets/javascripts/metrics/details/metrics_details.vue
+++ b/ee/app/assets/javascripts/metrics/details/metrics_details.vue
@@ -4,7 +4,7 @@ import EMPTY_CHART_SVG from '@gitlab/svgs/dist/illustrations/chart-empty-state.s
 import { uniqueId } from 'lodash';
 import { s__, __ } from '~/locale';
 import { createAlert } from '~/alert';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { visitUrl, visitUrlWithAlerts } from '~/lib/utils/url_utility';
 import {
   prepareTokens,
   processFilters as processFilteredSearchFilters,
@@ -17,6 +17,8 @@ import PageHeading from '~/vue_shared/components/page_heading.vue';
 import RelatedIssuesBadge from '~/observability/components/related_issues_badge.vue';
 import RelatedIssue from '~/observability/components/observability_related_issues.vue';
 import { helpPagePath } from '~/helpers/help_page_helper';
+import { logError } from '~/lib/logger';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
 import { ingestedAtTimeAgo } from '../utils';
 import { VIEW_METRICS_DETAILS_PAGE } from '../events';
 import MetricsLineChart from './metrics_line_chart.vue';
@@ -26,6 +28,7 @@ import MetricsHeatMap from './metrics_heatmap.vue';
 import { createIssueUrlWithMetricDetails, isHistogram } from './utils';
 import RelatedIssuesProvider from './related_issues/related_issues_provider.vue';
 import RelatedTraces from './related_traces.vue';
+import { uploadMetricsSnapshot } from './metrics_snapshot';
 
 const VISUAL_HEATMAP = 'heatmap';
 
@@ -38,6 +41,9 @@ export default {
     lastIngested: s__('ObservabilityMetrics|Last ingested'),
     cancelledWarning: s__('ObservabilityMetrics|Metrics search has been cancelled.'),
     createIssueTitle: __('Create issue'),
+    creatingSnapshotError: s__(
+      'ObservabilityMetrics|Error: Unable to create metric snapshot image.',
+    ),
   },
   components: {
     GlSprintf,
@@ -80,6 +86,10 @@ export default {
       required: true,
       type: String,
     },
+    projectId: {
+      required: true,
+      type: Number,
+    },
     tracingIndexUrl: {
       type: String,
       required: true,
@@ -94,6 +104,7 @@ export default {
       loading: false,
       queryCancelled: false,
       selectedDatapoints: [],
+      creatingIssue: false,
     };
   },
   computed: {
@@ -129,14 +140,6 @@ export default {
     noMetric() {
       return !this.metricData || !this.metricData.length;
     },
-    createIssueUrlWithQuery() {
-      return createIssueUrlWithMetricDetails({
-        metricName: this.metricId,
-        metricType: this.metricType,
-        filters: this.filters,
-        createIssueUrl: this.createIssueUrl,
-      });
-    },
   },
   created() {
     this.validateAndFetch();
@@ -235,6 +238,49 @@ export default {
     onChartSelected(datapoints) {
       this.selectedDatapoints = datapoints;
     },
+    async uploadChartSnapshot() {
+      const chartDomElement = this.$refs.chartComponent?.$refs.chart;
+
+      if (!chartDomElement) return '';
+
+      return uploadMetricsSnapshot(chartDomElement, this.projectId, {
+        metricName: this.metricId,
+        metricType: this.metricType,
+        filters: this.filters,
+      });
+    },
+    buildIssueUrl(imageSnapshotUrl = '') {
+      return createIssueUrlWithMetricDetails({
+        metricName: this.metricId,
+        metricType: this.metricType,
+        filters: this.filters,
+        createIssueUrl: this.createIssueUrl,
+        imageSnapshotUrl,
+      });
+    },
+    async onCreateIssue() {
+      this.creatingIssue = true;
+
+      try {
+        const imageSnapshotUrl = await this.uploadChartSnapshot();
+
+        visitUrl(this.buildIssueUrl(imageSnapshotUrl));
+      } catch (error) {
+        // eslint-disable-next-line @gitlab/require-i18n-strings
+        logError('Unexpected error while uploading image', error);
+        Sentry.captureException(error);
+
+        visitUrlWithAlerts(this.buildIssueUrl(), [
+          {
+            id: 'metrics-snapshot-creation-failed',
+            message: this.$options.i18n.creatingSnapshotError,
+            variant: 'danger',
+          },
+        ]);
+      }
+
+      this.creatingIssue = false;
+    },
   },
   EMPTY_CHART_SVG,
   relatedIssuesHelpPath: helpPagePath('/operations/metrics', {
@@ -267,7 +313,12 @@ export default {
                 :error="error"
                 :anchor-id="$options.relatedIssuesId"
               />
-              <gl-button category="primary" variant="confirm" :href="createIssueUrlWithQuery">
+              <gl-button
+                category="primary"
+                variant="confirm"
+                :loading="creatingIssue"
+                @click="onCreateIssue"
+              >
                 {{ $options.i18n.createIssueTitle }}
               </gl-button>
             </template>
@@ -301,6 +352,7 @@ export default {
           <div v-if="metricData && metricData.length">
             <component
               :is="getChartComponent()"
+              ref="chartComponent"
               :metric-data="metricData"
               :loading="loading"
               :cancelled="queryCancelled"
diff --git a/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue b/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue
index 0c3891a1563e0..5936daf6495da 100644
--- a/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue
+++ b/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue
@@ -114,7 +114,7 @@ export default {
 </script>
 
 <template>
-  <div class="gl-relative">
+  <div ref="chart" class="gl-relative">
     <gl-heatmap
       :class="{ 'gl-opacity-3': loading || cancelled }"
       :x-axis-labels="xAxisLabels"
diff --git a/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue b/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue
index af49708abc273..478a681ee080d 100644
--- a/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue
+++ b/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue
@@ -49,9 +49,9 @@ export default {
   },
   computed: {
     bgColor() {
-      return this.$refs.rootContainer
+      return this.$refs.chart
         ? window
-            .getComputedStyle(this.$refs.rootContainer)
+            .getComputedStyle(this.$refs.chart)
             .getPropertyValue('--gl-background-color-default')
         : '#fff';
     },
@@ -166,7 +166,7 @@ export default {
 </script>
 
 <template>
-  <div ref="rootContainer" class="gl-relative">
+  <div ref="chart" class="gl-relative">
     <gl-line-chart
       disabled
       :class="{ 'gl-opacity-3': loading || cancelled }"
diff --git a/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js b/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js
new file mode 100644
index 0000000000000..e4ac1084b0121
--- /dev/null
+++ b/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js
@@ -0,0 +1,20 @@
+import { domElementToBlob } from '~/lib/utils/image_utils';
+import { uploadImageToProject } from '~/api/projects_api';
+import { getAbsoluteDateRange, getTimeframe } from 'ee/metrics/details/utils';
+import { slugify } from '~/lib/utils/text_utility';
+
+export async function uploadMetricsSnapshot(element, projectId, metricProperties) {
+  const timeFrame = getTimeframe(getAbsoluteDateRange(metricProperties.filters.dateRange));
+
+  // e.g. sum_metric_calls_tue-24-sep-2024-12-18-53-gmt_tue-24-sep-2024-13-18-53-gmt_snapshot.png
+  const filename = slugify(
+    `${metricProperties.metricType.toLowerCase()}_metric_${metricProperties.metricName}_${timeFrame.join('_')}_snapshot.png`,
+  );
+  const blobData = await domElementToBlob(element);
+
+  return uploadImageToProject({
+    blobData,
+    filename,
+    projectId,
+  });
+}
diff --git a/ee/app/assets/javascripts/metrics/details/utils.js b/ee/app/assets/javascripts/metrics/details/utils.js
index 58906209249ac..982b955129552 100644
--- a/ee/app/assets/javascripts/metrics/details/utils.js
+++ b/ee/app/assets/javascripts/metrics/details/utils.js
@@ -5,16 +5,26 @@ import { tracingListQueryFromAttributes } from 'ee/tracing/list/filter_bar/filte
 import { METRIC_TYPE } from '../constants';
 import { filterObjToQuery } from './filters';
 
+export function getAbsoluteDateRange(dateRange) {
+  if (dateRange.value === CUSTOM_DATE_RANGE_OPTION) {
+    return dateRange;
+  }
+
+  return periodToDateRange(dateRange.value);
+}
+
+export function getTimeframe(dateRange) {
+  return [dateRange.startDate.toUTCString(), dateRange.endDate.toUTCString()];
+}
+
 export function createIssueUrlWithMetricDetails({
   metricName,
   metricType,
   filters,
   createIssueUrl,
+  imageSnapshotUrl,
 }) {
-  const absoluteDateRange =
-    filters.dateRange.value === CUSTOM_DATE_RANGE_OPTION
-      ? filters.dateRange
-      : periodToDateRange(filters.dateRange.value);
+  const absoluteDateRange = getAbsoluteDateRange(filters.dateRange);
 
   const queryWithUpdatedDateRange = filterObjToQuery({
     ...filters,
@@ -27,7 +37,8 @@ export function createIssueUrlWithMetricDetails({
     }),
     name: metricName,
     type: metricType,
-    timeframe: [absoluteDateRange.startDate.toUTCString(), absoluteDateRange.endDate.toUTCString()],
+    timeframe: getTimeframe(absoluteDateRange),
+    imageSnapshotUrl: imageSnapshotUrl || undefined,
   };
 
   return createIssueUrlWithDetails(createIssueUrl, metricsDetails, 'observability_metric_details');
diff --git a/ee/app/assets/javascripts/metrics/details_index.vue b/ee/app/assets/javascripts/metrics/details_index.vue
index 8879508057124..3d232c49ed70d 100644
--- a/ee/app/assets/javascripts/metrics/details_index.vue
+++ b/ee/app/assets/javascripts/metrics/details_index.vue
@@ -31,6 +31,10 @@ export default {
       type: String,
       required: true,
     },
+    projectId: {
+      type: Number,
+      required: true,
+    },
     tracingIndexUrl: {
       type: String,
       required: true,
@@ -52,6 +56,7 @@ export default {
     :observability-client="observabilityClient"
     :create-issue-url="createIssueUrl"
     :project-full-path="projectFullPath"
+    :project-id="projectId"
     :tracing-index-url="tracingIndexUrl"
   />
 </template>
diff --git a/ee/app/helpers/observability/metrics_issues_helper.rb b/ee/app/helpers/observability/metrics_issues_helper.rb
index 82ba0217289a8..05b2ffc544037 100644
--- a/ee/app/helpers/observability/metrics_issues_helper.rb
+++ b/ee/app/helpers/observability/metrics_issues_helper.rb
@@ -14,12 +14,22 @@ def observability_metrics_issues_params(params)
     private
 
     def observability_metrics_issue_description(params)
-      <<~TEXT
+      description = <<~TEXT
         [Metric details](#{params['fullUrl']}) \\
         Name: `#{params['name']}` \\
         Type: `#{params['type']}` \\
         Timeframe: `#{params.dig('timeframe', 0)} - #{params.dig('timeframe', 1)}`
       TEXT
+
+      if params['imageSnapshotUrl'].present?
+        description += <<~TEXT
+            | Snapshot |
+            | ------ |
+            | ![metric_snapshot](#{params['imageSnapshotUrl']}) |
+        TEXT
+      end
+
+      description
     end
   end
 end
diff --git a/ee/app/helpers/projects/observability_helper.rb b/ee/app/helpers/projects/observability_helper.rb
index 4353dfbe9024f..150e4f6595e34 100644
--- a/ee/app/helpers/projects/observability_helper.rb
+++ b/ee/app/helpers/projects/observability_helper.rb
@@ -65,7 +65,8 @@ def shared_model(project)
           logsSearchUrl: ::Gitlab::Observability.logs_search_url(project),
           logsSearchMetadataUrl: ::Gitlab::Observability.logs_search_metadata_url(project)
         },
-        projectFullPath: project.full_path
+        projectFullPath: project.full_path,
+        projectId: project.id
       }
     end
   end
diff --git a/ee/spec/frontend/metrics/details/metrics_details_spec.js b/ee/spec/frontend/metrics/details/metrics_details_spec.js
index 24c987501dc87..92e632d4f0050 100644
--- a/ee/spec/frontend/metrics/details/metrics_details_spec.js
+++ b/ee/spec/frontend/metrics/details/metrics_details_spec.js
@@ -21,6 +21,11 @@ import RelatedIssue from '~/observability/components/observability_related_issue
 import { stubComponent } from 'helpers/stub_component';
 import { helpPagePath } from '~/helpers/help_page_helper';
 import RelatedIssuesBadge from '~/observability/components/related_issues_badge.vue';
+import { uploadMetricsSnapshot } from 'ee/metrics/details/metrics_snapshot';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import { logError } from '~/lib/logger';
+
+jest.mock('ee/metrics/details/metrics_snapshot');
 
 jest.mock('~/alert');
 jest.mock('~/lib/utils/axios_utils');
@@ -28,6 +33,8 @@ jest.mock('ee/metrics/utils');
 jest.mock('lodash/uniqueId', () => {
   return jest.fn((input) => `${input}1`);
 });
+jest.mock('~/lib/logger');
+jest.mock('~/sentry/sentry_browser_wrapper');
 
 describe('MetricsDetails', () => {
   let wrapper;
@@ -39,6 +46,7 @@ describe('MetricsDetails', () => {
   const createIssueUrl = 'https://www.gitlab.com/flightjs/Flight/-/issues/new';
   const tracingIndexUrl = 'https://www.gitlab.com/flightjs/Flight/-/tracing';
   const projectFullPath = 'test/project';
+  const projectId = 1234;
 
   const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
   const findMetricDetails = () => wrapper.findComponentByTestId('metric-details');
@@ -69,6 +77,7 @@ describe('MetricsDetails', () => {
     createIssueUrl,
     projectFullPath,
     tracingIndexUrl,
+    projectId,
   };
 
   const showToast = jest.fn();
@@ -547,9 +556,13 @@ describe('MetricsDetails', () => {
     expect(ingestedAtTimeAgo).toHaveBeenCalledWith(mockSearchMetadata.last_ingested_at);
   });
 
-  it('renders the create issue button', () => {
-    const button = findHeader().findComponent(GlButton);
-    expect(button.text()).toBe('Create issue');
+  describe('create issue', () => {
+    const findButton = () => findHeader().findComponent(GlButton);
+    const onButtonClicked = async () => {
+      await findButton().vm.$emit('click');
+      await waitForPromises();
+    };
+
     const metricsDetails = {
       fullUrl:
         'http://test.host/?type=Sum&date_range=custom&date_start=2020-07-05T23%3A00%3A00.000Z&date_end=2020-07-06T00%3A00%3A00.000Z',
@@ -557,11 +570,107 @@ describe('MetricsDetails', () => {
       type: 'Sum',
       timeframe: ['Sun, 05 Jul 2020 23:00:00 GMT', 'Mon, 06 Jul 2020 00:00:00 GMT'],
     };
-    expect(button.attributes('href')).toBe(
-      `${createIssueUrl}?observability_metric_details=${encodeURIComponent(
-        JSON.stringify(metricsDetails),
-      )}&${encodeURIComponent('issue[confidential]')}=true`,
-    );
+
+    let visitUrlMock;
+    let visitUrlWithAlertsMock;
+
+    beforeEach(() => {
+      visitUrlMock = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+      visitUrlWithAlertsMock = jest.spyOn(urlUtility, 'visitUrlWithAlerts').mockReturnValue({});
+      wrapper.vm.$refs.chartComponent = { $refs: { chart: {} } };
+      uploadMetricsSnapshot.mockResolvedValue('http://test.host/share/url');
+    });
+
+    it('renders the create issue button', () => {
+      const button = findButton();
+      expect(button.text()).toBe('Create issue');
+    });
+
+    describe('on button clicked', () => {
+      beforeEach(async () => {
+        await onButtonClicked();
+      });
+
+      it('uploads the metric snapshot', () => {
+        expect(uploadMetricsSnapshot).toHaveBeenCalledWith(
+          wrapper.vm.$refs.chartComponent.$refs.chart,
+          projectId,
+          {
+            metricName: metricsDetails.name,
+            metricType: metricsDetails.type,
+            filters: expect.objectContaining({
+              dateRange: { value: '1h' },
+            }),
+          },
+        );
+      });
+
+      it('does not add the loading icon to the create issue button when done uploading the snapshot', () => {
+        expect(findButton().props('loading')).toBe(false);
+      });
+
+      it('appends the share url to the create issue params if uploadMetricsSnapshot succeeds', () => {
+        expect(visitUrlMock).toHaveBeenCalledWith(
+          `${createIssueUrl}?observability_metric_details=${encodeURIComponent(
+            JSON.stringify({ ...metricsDetails, imageSnapshotUrl: 'http://test.host/share/url' }),
+          )}&${encodeURIComponent('issue[confidential]')}=true`,
+        );
+      });
+    });
+
+    it('redirects to the create issue page without the image url if share url is not returned', async () => {
+      uploadMetricsSnapshot.mockResolvedValue(null);
+
+      await onButtonClicked();
+
+      expect(visitUrlMock).toHaveBeenCalledWith(
+        `${createIssueUrl}?observability_metric_details=${encodeURIComponent(
+          JSON.stringify(metricsDetails),
+        )}&${encodeURIComponent('issue[confidential]')}=true`,
+      );
+    });
+
+    it('does not upload the metric snapshot if the chart does not exist', async () => {
+      wrapper.vm.$refs.chartComponent = { $refs: { chart: null } };
+
+      await onButtonClicked();
+
+      expect(uploadMetricsSnapshot).not.toHaveBeenCalled();
+    });
+
+    it('redirects to the create issue page with alerts if an unexpected error is thrown', async () => {
+      const mockError = new Error('Upload failed');
+
+      uploadMetricsSnapshot.mockRejectedValue(mockError);
+
+      await onButtonClicked();
+
+      expect(logError).toHaveBeenCalledWith('Unexpected error while uploading image', mockError);
+      expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
+      expect(visitUrlWithAlertsMock).toHaveBeenCalledWith(
+        `${createIssueUrl}?observability_metric_details=${encodeURIComponent(
+          JSON.stringify(metricsDetails),
+        )}&${encodeURIComponent('issue[confidential]')}=true`,
+        [
+          {
+            id: 'metrics-snapshot-creation-failed',
+            message: 'Error: Unable to create metric snapshot image.',
+            variant: 'danger',
+          },
+        ],
+      );
+    });
+
+    describe('while uploading the snapshot', () => {
+      beforeEach(async () => {
+        uploadMetricsSnapshot.mockReturnValue(new Promise(() => {}));
+        await onButtonClicked();
+      });
+
+      it('adds a loading icon to the create issue button while uploading the snapshot', () => {
+        expect(findButton().props('loading')).toBe(true);
+      });
+    });
   });
 
   it('renders the relate issues badge', () => {
diff --git a/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js b/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js
new file mode 100644
index 0000000000000..debc911ae46e4
--- /dev/null
+++ b/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js
@@ -0,0 +1,39 @@
+import { uploadMetricsSnapshot } from 'ee/metrics/details/metrics_snapshot';
+import { domElementToBlob } from '~/lib/utils/image_utils';
+import { uploadImageToProject } from '~/api/projects_api';
+import { useFakeDate } from 'helpers/fake_date';
+
+jest.mock('~/lib/utils/image_utils');
+jest.mock('~/api/projects_api');
+
+describe('uploadMetricsSnapshot', () => {
+  useFakeDate('2024-08-01 11:00:00');
+
+  const mockElement = document.createElement('div');
+  const mockProjectId = 123;
+  const mockBlobData = new Blob(['mock data']);
+  const mockShareUrl = 'https://example.com/share/image.png';
+  const metricProperties = {
+    metricName: 'test.metric',
+    metricType: 'Sum',
+    filters: { dateRange: { value: '5m' } },
+  };
+
+  beforeEach(() => {
+    domElementToBlob.mockResolvedValue(mockBlobData);
+    uploadImageToProject.mockResolvedValue(mockShareUrl);
+  });
+
+  it('should upload a metrics snapshot', async () => {
+    const result = await uploadMetricsSnapshot(mockElement, mockProjectId, metricProperties);
+
+    expect(domElementToBlob).toHaveBeenCalledWith(mockElement);
+    expect(uploadImageToProject).toHaveBeenCalledWith({
+      blobData: mockBlobData,
+      filename:
+        'sum_metric_test.metric_thu-01-aug-2024-10-55-00-gmt_thu-01-aug-2024-11-00-00-gmt_snapshot.png',
+      projectId: mockProjectId,
+    });
+    expect(result).toBe(mockShareUrl);
+  });
+});
diff --git a/ee/spec/frontend/metrics/details/utils_spec.js b/ee/spec/frontend/metrics/details/utils_spec.js
index e907c18ccc8c2..42f46d7f40ab3 100644
--- a/ee/spec/frontend/metrics/details/utils_spec.js
+++ b/ee/spec/frontend/metrics/details/utils_spec.js
@@ -1,4 +1,6 @@
 import {
+  getAbsoluteDateRange,
+  getTimeframe,
   createIssueUrlWithMetricDetails,
   viewTracesUrlWithMetric,
   isHistogram,
@@ -6,12 +8,56 @@ import {
 import setWindowLocation from 'helpers/set_window_location_helper';
 import { useFakeDate } from 'helpers/fake_date';
 
+describe('getAbsoluteDateRange', () => {
+  useFakeDate('2024-08-01 11:00:00');
+
+  it('returns the absolute date range for custom dates', () => {
+    const dateRange = {
+      value: 'custom',
+      startDate: new Date('2023-01-01'),
+      endDate: new Date('2023-01-31'),
+    };
+
+    expect(getAbsoluteDateRange(dateRange)).toStrictEqual({
+      endDate: new Date('2023-01-31'),
+      startDate: new Date('2023-01-01'),
+      value: 'custom',
+    });
+  });
+
+  it('returns the absolute date range for date periods', () => {
+    const dateRange = { value: '5m' };
+
+    expect(getAbsoluteDateRange(dateRange)).toStrictEqual({
+      endDate: new Date('2024-08-01 11:00:00'),
+      startDate: new Date('2024-08-01 10:55:00'),
+      value: 'custom',
+    });
+  });
+});
+
+describe('getTimeframe', () => {
+  it('returns the timeframe array', () => {
+    const dateRange = {
+      value: 'custom',
+      startDate: new Date('2023-01-01'),
+      endDate: new Date('2023-01-31'),
+    };
+
+    expect(getTimeframe(dateRange)).toStrictEqual([
+      'Sun, 01 Jan 2023 00:00:00 GMT',
+      'Tue, 31 Jan 2023 00:00:00 GMT',
+    ]);
+  });
+});
+
 describe('createIssueUrlWithMetricDetails', () => {
   useFakeDate('2024-08-01 11:00:00');
 
   const metricName = 'Test Metric';
   const metricType = 'Sum';
   const createIssueUrl = 'https://example.com/issues/new';
+  const imageSnapshotUrl = 'https://example.com/image.png';
   const filters = {
     dateRange: {
       value: '5m',
@@ -34,11 +80,18 @@ describe('createIssueUrlWithMetricDetails', () => {
       name: metricName,
       type: metricType,
       timeframe: ['Thu, 01 Aug 2024 10:55:00 GMT', 'Thu, 01 Aug 2024 11:00:00 GMT'],
+      imageSnapshotUrl,
     };
     const expectedUrl = `https://example.com/issues/new?observability_metric_details=${encodeURIComponent(JSON.stringify(metricsDetails))}&${encodeURIComponent('issue[confidential]')}=true`;
 
     expect(
-      createIssueUrlWithMetricDetails({ metricName, metricType, filters, createIssueUrl }),
+      createIssueUrlWithMetricDetails({
+        metricName,
+        metricType,
+        filters,
+        createIssueUrl,
+        imageSnapshotUrl,
+      }),
     ).toBe(expectedUrl);
   });
 
diff --git a/ee/spec/frontend/metrics/details_index_spec.js b/ee/spec/frontend/metrics/details_index_spec.js
index d0463c9d55e8c..54787a94096f1 100644
--- a/ee/spec/frontend/metrics/details_index_spec.js
+++ b/ee/spec/frontend/metrics/details_index_spec.js
@@ -13,6 +13,7 @@ describe('DetailsIndex', () => {
     tracingIndexUrl: 'https://example.com/traces/index',
     apiConfig: { ...mockApiConfig },
     projectFullPath: 'foo/bar',
+    projectId: 1234,
   };
 
   let wrapper;
@@ -39,6 +40,7 @@ describe('DetailsIndex', () => {
     expect(detailsCmp.props('metricType')).toBe(props.metricType);
     expect(detailsCmp.props('createIssueUrl')).toBe(props.createIssueUrl);
     expect(detailsCmp.props('projectFullPath')).toBe(props.projectFullPath);
+    expect(detailsCmp.props('projectId')).toBe(props.projectId);
     expect(detailsCmp.props('tracingIndexUrl')).toBe(props.tracingIndexUrl);
   });
 
diff --git a/ee/spec/helpers/observability/metrics_issues_helper_spec.rb b/ee/spec/helpers/observability/metrics_issues_helper_spec.rb
index 12a26afbb4d08..27350b5e9c8ae 100644
--- a/ee/spec/helpers/observability/metrics_issues_helper_spec.rb
+++ b/ee/spec/helpers/observability/metrics_issues_helper_spec.rb
@@ -35,6 +35,22 @@
           TEXT
         )
       end
+
+      it 'adds the image to the text description if imageSnapshotUrl is present' do
+        params['imageSnapshotUrl'] = 'http://example.com/image.png'
+        result = helper.observability_metrics_issues_params(params)
+        expect(result[:description]).to eq(
+          <<~TEXT
+            [Metric details](http://example.com/metric/123) \\
+            Name: `CPU Usage High` \\
+            Type: `gauge` \\
+            Timeframe: `2024-08-14T00:00:00Z - 2024-08-14T23:59:59Z`
+            | Snapshot |
+            | ------ |
+            | ![metric_snapshot](http://example.com/image.png) |
+          TEXT
+        )
+      end
     end
   end
 end
diff --git a/ee/spec/helpers/projects/observability_helper_spec.rb b/ee/spec/helpers/projects/observability_helper_spec.rb
index bfd8cd3ac9488..b42c4c88187ef 100644
--- a/ee/spec/helpers/projects/observability_helper_spec.rb
+++ b/ee/spec/helpers/projects/observability_helper_spec.rb
@@ -28,7 +28,8 @@
     it 'generates the correct JSON' do
       expected_json = {
         apiConfig: expected_api_config,
-        projectFullPath: project.full_path
+        projectFullPath: project.full_path,
+        projectId: project.id
       }.to_json
 
       expect(helper.observability_tracing_view_model(project)).to eq(expected_json)
@@ -40,6 +41,7 @@
       expected_json = {
         apiConfig: expected_api_config,
         projectFullPath: project.full_path,
+        projectId: project.id,
         traceId: "trace-id",
         tracingIndexUrl: namespace_project_tracing_index_path(project.group, project),
         logsIndexUrl: namespace_project_logs_path(project.group, project),
@@ -55,7 +57,8 @@
     it 'generates the correct JSON' do
       expected_json = {
         apiConfig: expected_api_config,
-        projectFullPath: project.full_path
+        projectFullPath: project.full_path,
+        projectId: project.id
       }.to_json
 
       expect(helper.observability_metrics_view_model(project)).to eq(expected_json)
@@ -67,6 +70,7 @@
       expected_json = {
         apiConfig: expected_api_config,
         projectFullPath: project.full_path,
+        projectId: project.id,
         metricId: "test.metric",
         metricType: "metric_type",
         metricsIndexUrl: namespace_project_metrics_path(project.group, project),
@@ -84,6 +88,7 @@
       expected_json = {
         apiConfig: expected_api_config,
         projectFullPath: project.full_path,
+        projectId: project.id,
         tracingIndexUrl: namespace_project_tracing_index_path(project.group, project),
         createIssueUrl: new_namespace_project_issue_path(project.group, project)
       }.to_json
@@ -96,7 +101,8 @@
     it 'generates the correct JSON' do
       expected_json = {
         apiConfig: expected_api_config,
-        projectFullPath: project.full_path
+        projectFullPath: project.full_path,
+        projectId: project.id
       }.to_json
 
       expect(helper.observability_usage_quota_view_model(project)).to eq(expected_json)
diff --git a/ee/spec/requests/projects/logs_controller_spec.rb b/ee/spec/requests/projects/logs_controller_spec.rb
index d57a268eda711..4b57efdc01cd5 100644
--- a/ee/spec/requests/projects/logs_controller_spec.rb
+++ b/ee/spec/requests/projects/logs_controller_spec.rb
@@ -80,6 +80,7 @@
         expected_view_model = {
           apiConfig: expected_api_config,
           projectFullPath: project.full_path,
+          projectId: project.id,
           tracingIndexUrl: namespace_project_tracing_index_path(project.group, project),
           createIssueUrl: new_namespace_project_issue_path(project.group, project)
         }.to_json
diff --git a/ee/spec/requests/projects/metrics_controller_spec.rb b/ee/spec/requests/projects/metrics_controller_spec.rb
index 0a6f073478327..4f8a5c2d4feb3 100644
--- a/ee/spec/requests/projects/metrics_controller_spec.rb
+++ b/ee/spec/requests/projects/metrics_controller_spec.rb
@@ -79,7 +79,8 @@
 
         expected_view_model = {
           apiConfig: expected_api_config,
-          projectFullPath: project.full_path
+          projectFullPath: project.full_path,
+          projectId: project.id
         }.to_json
         expect(element.attributes['data-view-model'].value).to eq(expected_view_model)
       end
@@ -102,6 +103,7 @@
         expected_view_model = {
           apiConfig: expected_api_config,
           projectFullPath: project.full_path,
+          projectId: project.id,
           metricId: "test.metric",
           metricType: "metric_type",
           metricsIndexUrl: namespace_project_metrics_path(project.group, project),
diff --git a/ee/spec/requests/projects/tracing_controller_spec.rb b/ee/spec/requests/projects/tracing_controller_spec.rb
index 905a8ec19e77e..5646b37f3cddf 100644
--- a/ee/spec/requests/projects/tracing_controller_spec.rb
+++ b/ee/spec/requests/projects/tracing_controller_spec.rb
@@ -79,7 +79,8 @@
 
         expected_view_model = {
           apiConfig: expected_api_config,
-          projectFullPath: project.full_path
+          projectFullPath: project.full_path,
+          projectId: project.id
         }.to_json
         expect(element.attributes['data-view-model'].value).to eq(expected_view_model)
       end
@@ -102,6 +103,7 @@
         expected_view_model = {
           apiConfig: expected_api_config,
           projectFullPath: project.full_path,
+          projectId: project.id,
           traceId: 'test-trace-id',
           tracingIndexUrl: project_tracing_index_path(project),
           logsIndexUrl: namespace_project_logs_path(project.group, project),
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 438809ccddc10..d86046ae20d1a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -37229,6 +37229,9 @@ msgstr ""
 msgid "ObservabilityMetrics|Error: Failed to load metrics details. Try reloading the page."
 msgstr ""
 
+msgid "ObservabilityMetrics|Error: Unable to create metric snapshot image."
+msgstr ""
+
 msgid "ObservabilityMetrics|Failed to load metrics."
 msgstr ""
 
diff --git a/package.json b/package.json
index 518679ae28b2c..d57bcac271bc2 100644
--- a/package.json
+++ b/package.json
@@ -184,6 +184,7 @@
     "mermaid": "10.7.0",
     "micromatch": "^4.0.5",
     "minimatch": "^3.0.4",
+    "modern-screenshot": "^4.4.39",
     "monaco-editor": "^0.30.1",
     "monaco-editor-webpack-plugin": "^6.0.0",
     "monaco-yaml": "4.0.0",
diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js
index 7dd686d04cf3e..76e98165f9290 100644
--- a/spec/frontend/api/projects_api_spec.js
+++ b/spec/frontend/api/projects_api_spec.js
@@ -219,4 +219,89 @@ describe('~/api/projects_api.js', () => {
       expect(mock.history.get[0].mockOption).toBe(axiosOptions.mockOption);
     });
   });
+
+  describe('uploadImageToProject', () => {
+    const mockProjectId = 123;
+    const mockFilename = 'test.jpg';
+    const mockBlobData = new Blob(['test']);
+
+    beforeEach(() => {
+      window.gon = { relative_url_root: '', api_version: 'v7' };
+      jest.spyOn(axios, 'post');
+    });
+
+    it('should upload an image and return the share URL', async () => {
+      const mockResponse = {
+        full_path: '/-/project/123/uploads/abcd/test.jpg',
+      };
+
+      mock.onPost().replyOnce(HTTP_STATUS_OK, mockResponse);
+
+      const result = await projectsApi.uploadImageToProject({
+        filename: mockFilename,
+        blobData: mockBlobData,
+        projectId: mockProjectId,
+      });
+
+      expect(axios.post).toHaveBeenCalledWith(
+        `/api/v7/projects/${mockProjectId}/uploads`,
+        expect.any(FormData),
+        expect.objectContaining({
+          headers: { 'Content-Type': 'multipart/form-data' },
+        }),
+      );
+      expect(result).toBe('http://test.host/-/project/123/uploads/abcd/test.jpg');
+    });
+
+    it('should throw an error if filename is missing', async () => {
+      await expect(
+        projectsApi.uploadImageToProject({
+          blobData: mockBlobData,
+          projectId: mockProjectId,
+        }),
+      ).rejects.toThrow('Request failed with status code 404');
+    });
+
+    it('should throw an error if blobData is missing', async () => {
+      await expect(
+        projectsApi.uploadImageToProject({
+          filename: mockFilename,
+          projectId: mockProjectId,
+        }),
+      ).rejects.toThrow("is not of type 'Blob'");
+    });
+
+    it('should throw an error if projectId is missing', async () => {
+      await expect(
+        projectsApi.uploadImageToProject({
+          filename: mockFilename,
+          blobData: mockBlobData,
+        }),
+      ).rejects.toThrow('Request failed with status code 404');
+    });
+
+    it('should throw an error if the upload fails', async () => {
+      mock.onPost().replyOnce(500);
+
+      await expect(
+        projectsApi.uploadImageToProject({
+          filename: mockFilename,
+          blobData: mockBlobData,
+          projectId: mockProjectId,
+        }),
+      ).rejects.toThrow('Request failed with status code 500');
+    });
+
+    it('should throw an error if the response does not have a link', async () => {
+      mock.onPost().replyOnce(HTTP_STATUS_OK, {});
+
+      await expect(
+        projectsApi.uploadImageToProject({
+          filename: mockFilename,
+          blobData: mockBlobData,
+          projectId: mockProjectId,
+        }),
+      ).rejects.toThrow('Image failed to upload');
+    });
+  });
 });
diff --git a/yarn.lock b/yarn.lock
index 8e12d95cdfa40..20d8901180ad2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10601,6 +10601,11 @@ mock-apollo-client@1.2.0:
   resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-1.2.0.tgz#72543df0d74577d29be1b34cecba8898c7e71451"
   integrity sha512-zCVHv3p7zvUmen9zce9l965ZrI6rMbrm2/oqGaTerVYOaYskl/cVgTG/L7iIToTIpI7onk/f6tu8hxPXZdyy/g==
 
+modern-screenshot@^4.4.39:
+  version "4.4.39"
+  resolved "https://registry.yarnpkg.com/modern-screenshot/-/modern-screenshot-4.4.39.tgz#4c8b7a9ecb899e68b6d4111abfa71043f326847a"
+  integrity sha512-p+I4yLZUDnoJMa5zoi+71nLQmoLQ6WRU4W8vZu1BZk2PlIYOz5mGnj9/7t2lGWKYeOr4zo6pajhY0/9TS5Zcdw==
+
 monaco-editor-webpack-plugin@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-6.0.0.tgz#628956ce1851afa2a5f6c88d0ecbb24e9a444898"
@@ -13111,16 +13116,7 @@ string-length@^4.0.1:
     char-regex "^1.0.2"
     strip-ansi "^6.0.0"
 
-"string-width-cjs@npm:string-width@^4.2.0":
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -13173,7 +13169,7 @@ string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -13187,13 +13183,6 @@ strip-ansi@^5.2.0:
   dependencies:
     ansi-regex "^4.1.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -14885,7 +14874,7 @@ worker-loader@^3.0.8:
     loader-utils "^2.0.0"
     schema-utils "^3.0.0"
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -14903,15 +14892,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
-- 
GitLab