Dashboard Library: Implement analytics tracking for Suggested Dashboards (#113417)

Implement analytics tracking for Suggested Dashboard
* loaded - Tracks when library content becomes available
* searchPerformed - Tracks search behavior (privacy-preserving, no query text)
* itemClicked - Tracks dashboard selection
* mappingFormShown - Tracks when datasource mapping form is displayed
* mappingFormCompleted - Tracks successful mapping form completion

---------

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Alexa Vargas
2025-11-12 17:13:31 +01:00
committed by GitHub
co-authored by Juan Cabanas nmarrs
parent d35524915c
commit 6eabb9b2e4
11 changed files with 341 additions and 98 deletions
@@ -1,5 +1,6 @@
import { store } from '@grafana/data';
import { config } from '@grafana/runtime';
import { extractDatasourceTypesFromUrl } from 'app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
@@ -55,16 +56,18 @@ export function trackDashboardSceneCreatedOrSaved(
) {
// url values for dashboard library experiment
const urlParams = new URLSearchParams(window.location.search);
const pluginId = urlParams.get('pluginId') || undefined;
const sourceEntryPoint = urlParams.get('sourceEntryPoint') || undefined;
const libraryItemId = urlParams.get('libraryItemId') || undefined;
// For community dashboards, use gnetId as libraryItemId if libraryItemId is not present
const libraryItemId = urlParams.get('libraryItemId') || urlParams.get('gnetId') || undefined;
const creationOrigin = urlParams.get('creationOrigin') || undefined;
// Extract datasourceTypes from URL params (supports both community and provisioned dashboards)
const datasourceTypes = extractDatasourceTypesFromUrl();
const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation();
const dashboardLibraryProperties = config.featureToggles.dashboardLibrary
? {
datasourceTypes: [pluginId],
datasourceTypes,
sourceEntryPoint,
libraryItemId,
creationOrigin,
@@ -11,7 +11,14 @@ import { PluginDashboard } from 'app/types/plugins';
import { DASHBOARD_LIBRARY_ROUTES } from '../types';
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import {
CONTENT_KINDS,
CREATION_ORIGINS,
DashboardLibraryInteractions,
DISCOVERY_METHODS,
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
interface Props {
@@ -42,12 +49,13 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
const onImportDashboardClick = async (dashboard: PluginDashboard) => {
DashboardLibraryInteractions.itemClicked({
contentKind: 'datasource_dashboard',
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
datasourceTypes: [dashboard.pluginId],
libraryItemId: dashboard.uid,
libraryItemTitle: dashboard.title,
sourceEntryPoint: 'datasource_page',
eventLocation: 'empty_dashboard',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
const params = new URLSearchParams({
@@ -56,9 +64,9 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) =>
pluginId: dashboard.pluginId,
path: dashboard.path,
// tracking event purpose values
sourceEntryPoint: 'datasource_page',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
libraryItemId: dashboard.uid,
creationOrigin: 'dashboard_library_datasource_dashboard',
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD,
});
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -7,14 +7,20 @@ import { Stack, Text, Button, Alert, Field, Input, Box } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { ContentKind, DashboardLibraryInteractions, EventLocation, SOURCE_ENTRY_POINTS } from './interactions';
import { InputMapping, mapConstantInputs, mapUserSelectedDatasources } from './utils/autoMapDatasources';
interface Props {
unmappedInputs: DataSourceInput[];
unmappedDsInputs: DataSourceInput[];
constantInputs: DashboardInput[];
existingMappings: InputMapping[];
onBack: () => void;
onPreview: (allMappings: InputMapping[]) => void;
dashboardName: string;
libraryItemId: string;
eventLocation: EventLocation;
contentKind: ContentKind;
datasourceTypes: string[];
}
interface UserSelectedDatasourceMappings {
@@ -24,16 +30,36 @@ interface UserSelectedDatasourceMappings {
}
export const CommunityDashboardMappingForm = ({
unmappedInputs,
unmappedDsInputs,
constantInputs,
existingMappings,
onBack,
onPreview,
dashboardName,
libraryItemId,
eventLocation,
contentKind,
datasourceTypes,
}: Props) => {
// Track mapping form shown on mount
useEffect(() => {
DashboardLibraryInteractions.mappingFormShown({
contentKind,
datasourceTypes,
libraryItemId,
libraryItemTitle: dashboardName,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation,
unmappedDsInputsCount: unmappedDsInputs.length,
constantInputsCount: constantInputs.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [userSelectedDsMappings, setUserSelectedDsMappings] = useState<Record<string, UserSelectedDatasourceMappings>>(
() => {
// Initialize with existing unmapped inputs
return unmappedInputs.reduce<Record<string, UserSelectedDatasourceMappings>>((acc, input) => {
return unmappedDsInputs.reduce<Record<string, UserSelectedDatasourceMappings>>((acc, input) => {
const unmappedInput = {
name: input.name,
pluginId: input.pluginId,
@@ -71,12 +97,25 @@ export const CommunityDashboardMappingForm = ({
};
const onPreviewClick = () => {
// Track mapping form completion
DashboardLibraryInteractions.mappingFormCompleted({
contentKind,
datasourceTypes,
libraryItemId,
libraryItemTitle: dashboardName,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation,
userMappedCount: unmappedDsInputs.length,
autoMappedCount: existingMappings.length,
});
// Combine all mappings:
// 1. Existing auto-mapped datasources
// 2. User-selected datasources
// 3. Constant values (user-edited or defaults)
const userSelectedDatasources = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings);
const userSelectedDatasources = mapUserSelectedDatasources(unmappedDsInputs, userSelectedDsMappings);
const constantMappings = mapConstantInputs(constantInputs, constantValues);
const allMappings = [...existingMappings, ...userSelectedDatasources, ...constantMappings];
@@ -85,7 +124,7 @@ export const CommunityDashboardMappingForm = ({
// Check if all unmapped datasource inputs have been mapped by user
// Constants are optional (have default values)
const allDatasourcesMapped = unmappedInputs.every((input) => userSelectedDsMappings[input.name]?.datasource);
const allDatasourcesMapped = unmappedDsInputs.every((input) => userSelectedDsMappings[input.name]?.datasource);
return (
<Stack direction="column" gap={3} height="100%" justifyContent="space-between">
@@ -116,14 +155,14 @@ export const CommunityDashboardMappingForm = ({
</Alert>
)}
{unmappedInputs.length > 0 && (
{unmappedDsInputs.length > 0 && (
<Stack direction="column" gap={2}>
<Text element="h4" weight="medium">
<Trans i18nKey="dashboard-library.community-mapping-form.datasources-title">
Datasource Configuration
</Trans>
</Text>
{unmappedInputs.map((input) => {
{unmappedDsInputs.map((input) => {
const selectedDatasource = userSelectedDsMappings[input.name]?.datasource;
return (
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync, useDebounce } from 'react-use';
@@ -11,7 +11,13 @@ import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterI
import { DashboardCard } from './DashboardCard';
import { MappingContext } from './SuggestedDashboardsModal';
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import {
CONTENT_KINDS,
DashboardLibraryInteractions,
DISCOVERY_METHODS,
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { GnetDashboard } from './types';
import {
getThumbnailUrl,
@@ -38,6 +44,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const hasTrackedLoaded = useRef(false);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
useDebounce(
@@ -81,6 +88,17 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
filter: debouncedSearchQuery.trim() || undefined,
});
// Track search if query is present
if (debouncedSearchQuery.trim()) {
DashboardLibraryInteractions.searchPerformed({
datasourceTypes: [ds.type],
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
hasResults: apiResponse.dashboards.length > 0,
resultCount: apiResponse.dashboards.length,
});
}
return {
dashboards: apiResponse.dashboards,
pages: apiResponse.pages,
@@ -93,25 +111,18 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
}, [datasourceUid, currentPage, debouncedSearchQuery]);
// Track analytics only once on first successful load
const hasTrackedRef = useRef(false);
useEffect(() => {
if (
!loading &&
!hasTrackedRef.current &&
currentPage === 1 &&
response?.dashboards &&
response.dashboards.length > 0
) {
if (!loading && !hasTrackedLoaded.current && response?.dashboards && response.dashboards.length > 0) {
DashboardLibraryInteractions.loaded({
numberOfItems: response.dashboards.length,
contentKinds: ['community_dashboard'],
contentKinds: [CONTENT_KINDS.COMMUNITY_DASHBOARD],
datasourceTypes: [response.datasourceType],
sourceEntryPoint: 'datasource_page',
eventLocation: 'suggested_dashboards_modal_community_tab',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
});
hasTrackedRef.current = true;
hasTrackedLoaded.current = true;
}
}, [loading, currentPage, response]);
}, [loading, response]);
const styles = useStyles2(getStyles);
@@ -126,11 +137,22 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro
return;
}
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [response.datasourceType],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE,
});
onUseCommunityDashboard({
dashboard,
datasourceUid: datasourceUid || '',
datasourceType: response.datasourceType,
eventLocation: 'suggested_dashboards_modal_community_tab',
eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB,
onShowMapping,
});
};
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
@@ -13,7 +13,14 @@ import { DASHBOARD_LIBRARY_ROUTES } from '../types';
import { DashboardCard } from './DashboardCard';
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import {
CONTENT_KINDS,
CREATION_ORIGINS,
DashboardLibraryInteractions,
DISCOVERY_METHODS,
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
// Constants for datasource-provided dashboards pagination
@@ -24,6 +31,7 @@ export const DashboardLibrarySection = () => {
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
const [currentPage, setCurrentPage] = useState(1);
const hasTrackedLoaded = useRef(false);
// Get datasource info for empty state
const datasourceType = useMemo(() => {
@@ -49,17 +57,16 @@ export const DashboardLibrarySection = () => {
}, [datasourceUid]);
// Track analytics only once on first successful load
const hasTrackedRef = useRef(false);
useEffect(() => {
if (!loading && !hasTrackedRef.current && templateDashboards && templateDashboards.length > 0) {
if (!loading && !hasTrackedLoaded.current && templateDashboards && templateDashboards.length > 0) {
DashboardLibraryInteractions.loaded({
numberOfItems: templateDashboards.length,
contentKinds: ['datasource_dashboard'],
contentKinds: [CONTENT_KINDS.DATASOURCE_DASHBOARD],
datasourceTypes: [datasourceType],
sourceEntryPoint: 'datasource_page',
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB,
});
hasTrackedRef.current = true;
hasTrackedLoaded.current = true;
}
}, [loading, templateDashboards, datasourceType]);
@@ -77,12 +84,13 @@ export const DashboardLibrarySection = () => {
const onUseProvisionedDashboard = async (dashboard: PluginDashboard) => {
DashboardLibraryInteractions.itemClicked({
contentKind: 'datasource_dashboard',
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
datasourceTypes: [dashboard.pluginId],
libraryItemId: dashboard.uid,
libraryItemTitle: dashboard.title,
sourceEntryPoint: 'datasource_page',
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
const params = new URLSearchParams({
@@ -91,9 +99,11 @@ export const DashboardLibrarySection = () => {
pluginId: dashboard.pluginId,
path: dashboard.path,
// tracking event purpose values
sourceEntryPoint: 'datasource_page',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
libraryItemId: dashboard.uid,
creationOrigin: 'dashboard_library_datasource_dashboard',
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD,
eventLocation: EVENT_LOCATIONS.MODAL_PROVISIONED_TAB,
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
});
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useEffect, useMemo, useState, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
@@ -12,7 +12,14 @@ import { PluginDashboard } from 'app/types/plugins';
import { DashboardCard } from './DashboardCard';
import { MappingContext, SuggestedDashboardsModal } from './SuggestedDashboardsModal';
import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from './interactions';
import {
CONTENT_KINDS,
CREATION_ORIGINS,
DashboardLibraryInteractions,
DISCOVERY_METHODS,
EVENT_LOCATIONS,
SOURCE_ENTRY_POINTS,
} from './interactions';
import { GnetDashboard } from './types';
import {
getThumbnailUrl,
@@ -44,6 +51,7 @@ const INCLUDE_LOGO = true;
export const SuggestedDashboards = ({ datasourceUid }: Props) => {
const styles = useStyles2(getStyles);
const hasTrackedLoaded = useRef(false);
const [searchParams, setSearchParams] = useSearchParams();
const showLibraryModal = searchParams.get('dashboardLibraryModal') === 'open';
@@ -144,22 +152,24 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
}, [result, loading]);
// Track analytics only once on first successful load
const hasTrackedRef = useRef(false);
useEffect(() => {
if (!loading && !hasTrackedRef.current && result && result.dashboards.length > 0) {
const contentKinds: Array<'datasource_dashboard' | 'community_dashboard'> = [
if (!loading && !hasTrackedLoaded.current && result && result.dashboards.length > 0) {
const contentKinds = [
...new Set(
result.dashboards.map((m) => (m.type === 'provisioned' ? 'datasource_dashboard' : 'community_dashboard'))
result.dashboards.map((m) =>
m.type === 'provisioned' ? CONTENT_KINDS.DATASOURCE_DASHBOARD : CONTENT_KINDS.COMMUNITY_DASHBOARD
)
),
];
DashboardLibraryInteractions.loaded({
numberOfItems: result.dashboards.length,
contentKinds,
datasourceTypes: [datasourceType],
sourceEntryPoint: 'datasource_page',
eventLocation: 'empty_dashboard',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
});
hasTrackedRef.current = true;
hasTrackedLoaded.current = true;
}
}, [loading, result, datasourceType]);
@@ -198,12 +208,13 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
}
DashboardLibraryInteractions.itemClicked({
contentKind: 'datasource_dashboard',
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
datasourceTypes: [ds.type],
libraryItemId: dashboard.uid,
libraryItemTitle: dashboard.title,
sourceEntryPoint: 'datasource_page',
eventLocation: 'empty_dashboard',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
// Navigate to template route (existing flow)
@@ -212,9 +223,11 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
title: dashboard.title || 'Template',
pluginId: dashboard.pluginId,
path: dashboard.path,
sourceEntryPoint: 'datasource_page',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
libraryItemId: dashboard.uid,
creationOrigin: 'dashboard_library_datasource_dashboard',
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD,
});
locationService.push(`/dashboard/template?${params.toString()}`);
@@ -230,11 +243,22 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => {
return;
}
// Track item click
DashboardLibraryInteractions.itemClicked({
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes: [ds.type],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
discoveryMethod: DISCOVERY_METHODS.BROWSE,
});
onUseCommunityDashboard({
dashboard,
datasourceUid,
datasourceType: ds.type,
eventLocation: 'empty_dashboard',
eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD,
onShowMapping: onShowMapping,
});
};
@@ -12,6 +12,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types';
import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm';
import { CommunityDashboardSection } from './CommunityDashboardSection';
import { DashboardLibrarySection } from './DashboardLibrarySection';
import { ContentKind, EventLocation } from './interactions';
import { InputMapping } from './utils/autoMapDatasources';
interface SuggestedDashboardsModalProps {
@@ -26,10 +27,13 @@ type ModalView = 'datasource' | 'community' | 'mapping';
export interface MappingContext {
dashboardName: string;
dashboardJson: DashboardJson;
unmappedInputs: DataSourceInput[];
unmappedDsInputs: DataSourceInput[];
constantInputs: DashboardInput[];
existingMappings: InputMapping[];
onInterpolateAndNavigate: (mappings: InputMapping[]) => void;
// Tracking context for analytics
eventLocation: EventLocation;
contentKind: ContentKind;
}
export const SuggestedDashboardsModal = ({
@@ -142,13 +146,18 @@ export const SuggestedDashboardsModal = ({
)}
{activeView === 'mapping' && mappingContext && (
<CommunityDashboardMappingForm
unmappedInputs={mappingContext.unmappedInputs}
unmappedDsInputs={mappingContext.unmappedDsInputs}
constantInputs={mappingContext.constantInputs}
existingMappings={mappingContext.existingMappings}
onBack={handleBackToDashboards}
onPreview={(allMappings) => {
mappingContext.onInterpolateAndNavigate(allMappings);
}}
dashboardName={mappingContext.dashboardName}
libraryItemId={String(mappingContext.dashboardJson.gnetId || '')}
eventLocation={mappingContext.eventLocation}
contentKind={mappingContext.contentKind}
datasourceTypes={[datasourceInfo.type]}
/>
)}
</TabContent>
@@ -2,7 +2,7 @@ import { getBackendSrv } from '@grafana/runtime';
import { DashboardJson } from 'app/features/manage-dashboards/types';
import { PluginDashboard } from 'app/types/plugins';
import { GnetDashboardsResponse } from '../types';
import { GnetDashboardsResponse, Link } from '../types';
/**
* Parameters for fetching community dashboards from Grafana.com
@@ -18,11 +18,28 @@ export interface FetchCommunityDashboardsParams {
filter?: string;
}
/**
* Dependency item from Grafana.com dashboard API
*/
export interface GnetDashboardDependency {
pluginSlug: string;
pluginTypeCode: 'app' | 'panel' | 'datasource' | 'grafana';
pluginName?: string;
pluginVersion?: string;
[key: string]: unknown;
}
/**
* Response from the Gnet API when fetching a single dashboard
*/
export interface GnetDashboardResponse {
json: DashboardJson;
dependencies?: {
items?: GnetDashboardDependency[];
direction?: 'asc' | 'desc';
orderBy?: string;
links?: Link[];
};
[key: string]: unknown;
}
@@ -2,18 +2,40 @@ import { reportInteraction } from '@grafana/runtime';
const SCHEMA_VERSION = 1;
type ContentKind = 'datasource_dashboard' | 'community_dashboard';
// in future this could also include "template_dashboard" if/when items become templates
// | 'template_dashboard';
// Constant values for tracking events
export const EVENT_LOCATIONS = {
EMPTY_DASHBOARD: 'empty_dashboard',
MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab',
MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab',
} as const;
type SourceEntryPoint = 'datasource_page';
// possible future flows onboarding, create-dashboard, empty states
// | 'create_dashboard' | 'empty_state';
export const CONTENT_KINDS = {
DATASOURCE_DASHBOARD: 'datasource_dashboard',
COMMUNITY_DASHBOARD: 'community_dashboard',
// in future this could also include "TEMPLATE_DASHBOARD" if/when items become templates
} as const;
type EventLocation =
| 'empty_dashboard'
| 'suggested_dashboards_modal_provisioned_tab'
| 'suggested_dashboards_modal_community_tab';
export const SOURCE_ENTRY_POINTS = {
DATASOURCE_PAGE: 'datasource_page',
// possible future flows: CREATE_DASHBOARD, EMPTY_STATE
} as const;
export const DISCOVERY_METHODS = {
SEARCH: 'search',
BROWSE: 'browse',
} as const;
export const CREATION_ORIGINS = {
DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD: 'dashboard_library_datasource_dashboard',
DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD: 'dashboard_library_community_dashboard',
} as const;
// Derive types from constant maps for single source of truth
export type EventLocation = (typeof EVENT_LOCATIONS)[keyof typeof EVENT_LOCATIONS];
export type ContentKind = (typeof CONTENT_KINDS)[keyof typeof CONTENT_KINDS];
export type SourceEntryPoint = (typeof SOURCE_ENTRY_POINTS)[keyof typeof SOURCE_ENTRY_POINTS];
export type DiscoveryMethod = (typeof DISCOVERY_METHODS)[keyof typeof DISCOVERY_METHODS];
export type CreationOrigin = (typeof CREATION_ORIGINS)[keyof typeof CREATION_ORIGINS];
export const DashboardLibraryInteractions = {
loaded: (properties: {
@@ -25,6 +47,15 @@ export const DashboardLibraryInteractions = {
}) => {
reportDashboardLibraryInteraction('loaded', properties);
},
searchPerformed: (properties: {
datasourceTypes: string[];
sourceEntryPoint: SourceEntryPoint;
eventLocation: EventLocation;
hasResults: boolean;
resultCount: number;
}) => {
reportDashboardLibraryInteraction('search_performed', properties);
},
itemClicked: (properties: {
contentKind: ContentKind;
datasourceTypes: string[];
@@ -32,9 +63,34 @@ export const DashboardLibraryInteractions = {
libraryItemTitle: string;
sourceEntryPoint: SourceEntryPoint;
eventLocation: EventLocation;
discoveryMethod: DiscoveryMethod;
}) => {
reportDashboardLibraryInteraction('item_clicked', properties);
},
mappingFormShown: (properties: {
contentKind: ContentKind;
datasourceTypes: string[];
libraryItemId: string;
libraryItemTitle: string;
sourceEntryPoint: SourceEntryPoint;
eventLocation: EventLocation;
unmappedDsInputsCount: number;
constantInputsCount: number;
}) => {
reportDashboardLibraryInteraction('mapping_form_shown', properties);
},
mappingFormCompleted: (properties: {
contentKind: ContentKind;
datasourceTypes: string[];
libraryItemId: string;
libraryItemTitle: string;
sourceEntryPoint: SourceEntryPoint;
eventLocation: EventLocation;
userMappedCount: number;
autoMappedCount: number;
}) => {
reportDashboardLibraryInteraction('mapping_form_completed', properties);
},
};
const reportDashboardLibraryInteraction = (name: string, properties?: Record<string, unknown>) => {
@@ -20,7 +20,7 @@ export function isDataSourceInput(input: Input): input is Input & DataSourceInpu
export interface AutoMapResult {
allMapped: boolean;
mappings: InputMapping[];
unmappedInputs: DataSourceInput[];
unmappedDsInputs: DataSourceInput[];
}
/**
@@ -35,7 +35,7 @@ export interface AutoMapResult {
*/
export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasourceUid: string): AutoMapResult {
const mappings: InputMapping[] = [];
const unmappedInputs: DataSourceInput[] = [];
const unmappedDsInputs: DataSourceInput[] = [];
for (const input of inputs) {
// Get all datasources compatible with this input's plugin type
@@ -70,14 +70,14 @@ export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasour
value: selectedDs,
});
} else {
unmappedInputs.push(input);
unmappedDsInputs.push(input);
}
}
return {
allMapped: unmappedInputs.length === 0,
allMapped: unmappedDsInputs.length === 0,
mappings,
unmappedInputs,
unmappedDsInputs,
};
}
@@ -3,12 +3,40 @@ import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
import { MappingContext } from '../SuggestedDashboardsModal';
import { fetchCommunityDashboard } from '../api/dashboardLibraryApi';
import { DashboardLibraryInteractions } from '../interactions';
import { fetchCommunityDashboard, GnetDashboardDependency } from '../api/dashboardLibraryApi';
import { CONTENT_KINDS, ContentKind, CREATION_ORIGINS, EventLocation, SOURCE_ENTRY_POINTS } from '../interactions';
import { GnetDashboard, Link } from '../types';
import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources';
/**
* Extract datasource types from URL parameters for tracking purposes.
* Supports two formats:
* - datasourceTypes: JSON array of datasource types (for community dashboards)
* - pluginId: Single datasource type (legacy format for provisioned dashboards)
*
* @returns Array of datasource type strings, or undefined if not available
*/
export function extractDatasourceTypesFromUrl(): string[] | undefined {
const params = locationService.getSearchObject();
const datasourceTypesParam = params.datasourceTypes;
const pluginIdParam = params.pluginId;
if (datasourceTypesParam && typeof datasourceTypesParam === 'string') {
try {
return JSON.parse(datasourceTypesParam);
} catch {
// If parsing fails, return undefined
return undefined;
}
} else if (pluginIdParam && typeof pluginIdParam === 'string') {
// Fallback to legacy pluginId for provisioned dashboards
return [pluginIdParam];
}
return undefined;
}
/**
* Extract thumbnail URL from dashboard screenshots
*/
@@ -86,17 +114,27 @@ export function navigateToTemplate(
dashboardTitle: string,
gnetId: number,
datasourceUid: string,
mappings: InputMapping[]
mappings: InputMapping[],
eventLocation: EventLocation,
contentKind: ContentKind,
datasourceTypes?: string[]
): void {
const searchParams = new URLSearchParams({
datasource: datasourceUid,
title: dashboardTitle,
gnetId: String(gnetId),
sourceEntryPoint: 'datasource_page',
creationOrigin: 'dashboard_library_community_dashboard',
sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE,
creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD,
contentKind,
eventLocation,
mappings: JSON.stringify(mappings),
});
// Add datasource types for tracking if available
if (datasourceTypes && datasourceTypes.length > 0) {
searchParams.set('datasourceTypes', JSON.stringify(datasourceTypes));
}
locationService.push({
pathname: DASHBOARD_LIBRARY_ROUTES.Template,
search: searchParams.toString(),
@@ -125,16 +163,8 @@ export async function onUseCommunityDashboard({
eventLocation,
onShowMapping,
}: UseCommunityDashboardParams): Promise<void> {
// Track analytics
DashboardLibraryInteractions.itemClicked({
contentKind: 'community_dashboard',
datasourceTypes: [datasourceType],
libraryItemId: String(dashboard.id),
libraryItemTitle: dashboard.name,
sourceEntryPoint: 'datasource_page',
eventLocation,
});
// Note: item_clicked tracking is done by the caller (CommunityDashboardSection or SuggestedDashboards)
// with the correct discoveryMethod before calling this function
try {
// Fetch full dashboard from Gcom, this is the JSON with __inputs
const fullDashboard = await fetchCommunityDashboard(dashboard.id);
@@ -143,6 +173,13 @@ export async function onUseCommunityDashboard({
// Parse datasource requirements from __inputs
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
// Extract datasource types for tracking purposes from dependencies
const datasourceTypes =
fullDashboard.dependencies?.items
?.filter((dep: GnetDashboardDependency) => dep.pluginTypeCode === 'datasource')
.map((dep: GnetDashboardDependency) => dep.pluginSlug)
.filter(Boolean) || [];
// Parse constant inputs - these always need user review
const constantInputs = parseConstantInputs(dashboardJson.__inputs || []);
@@ -151,22 +188,40 @@ export async function onUseCommunityDashboard({
// Decide whether to show mapping form or navigate directly
// Show mapping form if: (a) there are unmapped datasources OR (b) there are constants
const needsMapping = mappingResult.unmappedInputs.length > 0 || constantInputs.length > 0;
const needsMapping = mappingResult.unmappedDsInputs.length > 0 || constantInputs.length > 0;
if (!needsMapping) {
// No mapping needed - all datasources auto-mapped, no constants
navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappingResult.mappings);
navigateToTemplate(
dashboard.name,
dashboard.id,
datasourceUid,
mappingResult.mappings,
eventLocation,
CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes
);
} else {
// Show mapping form for unmapped datasources and/or constants
if (onShowMapping) {
onShowMapping({
dashboardName: dashboard.name,
dashboardJson,
unmappedInputs: mappingResult.unmappedInputs,
unmappedDsInputs: mappingResult.unmappedDsInputs,
constantInputs,
existingMappings: mappingResult.mappings,
eventLocation,
contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD,
onInterpolateAndNavigate: (mappings) =>
navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappings),
navigateToTemplate(
dashboard.name,
dashboard.id,
datasourceUid,
mappings,
eventLocation,
CONTENT_KINDS.COMMUNITY_DASHBOARD,
datasourceTypes
),
});
}
}