mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 19:44:55 +08:00
Compare commits
8 Commits
docs/add-t
...
eledobleef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc49c8d47a | ||
|
|
5c17e4e05e | ||
|
|
bdad692470 | ||
|
|
61097047ff | ||
|
|
5b234a251e | ||
|
|
9acafa2b50 | ||
|
|
80c7d17543 | ||
|
|
9a7a05be50 |
@@ -6,6 +6,7 @@
|
||||
"version": "12.4.0-pre",
|
||||
"repository": "github:grafana/grafana",
|
||||
"scripts": {
|
||||
"analytics-report": "node --experimental-strip-types ./scripts/cli/analytics/main.mts",
|
||||
"check-frontend-dev": "./scripts/check-frontend-dev.sh",
|
||||
"build": "NODE_ENV=production nx exec --verbose -- webpack --config scripts/webpack/webpack.prod.js",
|
||||
"build:nominify": "yarn run build -- --env noMinify=1",
|
||||
@@ -430,6 +431,7 @@
|
||||
"swagger-ui-react": "5.30.3",
|
||||
"symbol-observable": "4.0.0",
|
||||
"systemjs": "6.15.1",
|
||||
"ts-morph": "^27.0.2",
|
||||
"tslib": "2.8.1",
|
||||
"tween-functions": "^1.2.0",
|
||||
"type-fest": "^4.18.2",
|
||||
|
||||
@@ -181,3 +181,24 @@ When a violation is detected, the rule reports:
|
||||
```
|
||||
Import '../status-history/utils' reaches outside the 'histogram' plugin directory. Plugins should only import from external dependencies or relative paths within their own directory.
|
||||
```
|
||||
|
||||
### `tracking-event-creation`
|
||||
|
||||
Checks that the process to create a tracking event is followed in the right way.
|
||||
|
||||
#### `eventFactoryLiterals`
|
||||
|
||||
Check if the values passed to `createEventFactory` are literals.
|
||||
|
||||
```tsx
|
||||
// Bad ❌
|
||||
const repo = 'grafana';
|
||||
const createUnifiedHistoryEvent = createEventFactory(repo, 'unified_history');
|
||||
|
||||
// Bad ❌
|
||||
const history = 'history';
|
||||
const createUnifiedHistoryEvent = createEventFactory('grafana', `unified_${history}`);
|
||||
|
||||
// Good ✅
|
||||
const createUnifiedHistoryEvent = createEventFactory('grafana', 'unified_history');
|
||||
```
|
||||
|
||||
@@ -5,6 +5,7 @@ const themeTokenUsage = require('./rules/theme-token-usage.cjs');
|
||||
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
|
||||
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
|
||||
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
|
||||
const trackingEventCreation = require('./rules/tracking-event-creation.cjs');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -15,5 +16,6 @@ module.exports = {
|
||||
'no-restricted-img-srcs': noRestrictedImgSrcs,
|
||||
'consistent-story-titles': consistentStoryTitles,
|
||||
'no-plugin-external-import-paths': noPluginExternalImportPaths,
|
||||
'tracking-event-creation': trackingEventCreation,
|
||||
},
|
||||
};
|
||||
|
||||
158
packages/grafana-eslint-rules/rules/tracking-event-creation.cjs
Normal file
158
packages/grafana-eslint-rules/rules/tracking-event-creation.cjs
Normal file
@@ -0,0 +1,158 @@
|
||||
// @ts-check
|
||||
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
|
||||
|
||||
const createRule = ESLintUtils.RuleCreator(
|
||||
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
|
||||
);
|
||||
|
||||
const trackingEventCreation = createRule({
|
||||
create(context) {
|
||||
// Track what name createEventFactory is imported as
|
||||
let createEventFactoryName = 'createEventFactory';
|
||||
// Track if createEventFactory is imported
|
||||
let isCreateEventFactoryImported = false;
|
||||
// Track variables that store createEventFactory calls
|
||||
const eventFactoryVariables = new Set();
|
||||
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (node.imported.type === AST_NODE_TYPES.Identifier && node.imported.name === 'createEventFactory') {
|
||||
// Remember what name it was imported as (handles aliased imports)
|
||||
createEventFactoryName = node.local.name;
|
||||
isCreateEventFactoryImported = true;
|
||||
}
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Track variables initialized with createEventFactory calls
|
||||
if (
|
||||
node.init?.type === AST_NODE_TYPES.CallExpression &&
|
||||
node.init.callee.type === AST_NODE_TYPES.Identifier &&
|
||||
node.init.callee.name === createEventFactoryName
|
||||
) {
|
||||
const variableName = node.id.type === AST_NODE_TYPES.Identifier && node.id.name;
|
||||
if (variableName) {
|
||||
eventFactoryVariables.add(variableName);
|
||||
}
|
||||
|
||||
// Check if arguments are literals
|
||||
const args = node.init.arguments;
|
||||
const argsAreNotLiterals = args.some((arg) => arg.type !== AST_NODE_TYPES.Literal);
|
||||
if (argsAreNotLiterals) {
|
||||
return context.report({
|
||||
node: node.init,
|
||||
messageId: 'eventFactoryLiterals',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
|
||||
node.declaration.declarations[0].init?.type === AST_NODE_TYPES.CallExpression
|
||||
) {
|
||||
const callee = node.declaration.declarations[0].init.callee;
|
||||
if (callee.type === AST_NODE_TYPES.Identifier && eventFactoryVariables.has(callee.name)) {
|
||||
// Check for comments
|
||||
// Check for comments
|
||||
const comments = context.sourceCode.getCommentsBefore(node);
|
||||
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingFunctionComment',
|
||||
});
|
||||
}
|
||||
|
||||
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
|
||||
|
||||
if (!jsDocComment) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingJsDocComment',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
TSInterfaceDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Check if interface extends TrackingEvent
|
||||
let extendsTrackingEvent = false;
|
||||
if (node.extends && node.extends.length > 0) {
|
||||
const interfaceExtends = node.extends;
|
||||
extendsTrackingEvent = interfaceExtends.some((extend) => {
|
||||
return (
|
||||
extend.expression.type === AST_NODE_TYPES.Identifier && extend.expression.name === 'TrackingEventProps'
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!node.extends || !extendsTrackingEvent) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'interfaceMustExtend',
|
||||
});
|
||||
}
|
||||
//Check if the interface properties has comments
|
||||
if (node.body.type === AST_NODE_TYPES.TSInterfaceBody) {
|
||||
const properties = node.body.body;
|
||||
properties.forEach((property) => {
|
||||
const comments = context.sourceCode.getCommentsBefore(property);
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node: property,
|
||||
messageId: 'missingPropertyComment',
|
||||
});
|
||||
}
|
||||
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
|
||||
|
||||
if (!jsDocComment) {
|
||||
return context.report({
|
||||
node: property,
|
||||
messageId: 'missingJsDocComment',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
TSTypeAliasDeclaration(node) {
|
||||
if (!isCreateEventFactoryImported) {
|
||||
return;
|
||||
}
|
||||
// Check if types has comments
|
||||
const comments = context.sourceCode.getCommentsBefore(node);
|
||||
if (!comments || comments.length === 0) {
|
||||
return context.report({
|
||||
node,
|
||||
messageId: 'missingPropertyComment',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
name: 'tracking-event-creation',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Check that the tracking event is created in the right way',
|
||||
},
|
||||
messages: {
|
||||
eventFactoryLiterals: 'Params passed to `createEventFactory` must be literals',
|
||||
missingFunctionComment: 'Event function needs to have a description of its purpose',
|
||||
missingPropertyComment: 'Event property needs to have a description of its purpose',
|
||||
interfaceMustExtend: 'Interface must extend `TrackingEvent`',
|
||||
missingJsDocComment: 'Comment needs to be a jsDoc comment (begin comment with `*`)',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
});
|
||||
|
||||
module.exports = trackingEventCreation;
|
||||
@@ -43,7 +43,7 @@ export const reportPageview = () => {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const reportInteraction = (interactionName: string, properties?: Record<string, unknown>) => {
|
||||
export const reportInteraction = (interactionName: string, properties?: object) => {
|
||||
// get static reporting context and append it to properties
|
||||
if (config.reportingStaticContext && config.reportingStaticContext instanceof Object) {
|
||||
properties = { ...properties, ...config.reportingStaticContext };
|
||||
|
||||
@@ -141,6 +141,7 @@ export class AppChromeService {
|
||||
entries[0] = newEntry;
|
||||
} else {
|
||||
if (lastEntry && lastEntry.name === newEntry.name) {
|
||||
//Using new tracking event process
|
||||
logDuplicateUnifiedHistoryEntryEvent({
|
||||
entryName: newEntry.name,
|
||||
lastEntryURL: lastEntry.url,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function HistoryContainer() {
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
//Using new tracking event process
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'open' });
|
||||
}}
|
||||
iconOnly
|
||||
@@ -65,6 +66,7 @@ export function HistoryContainer() {
|
||||
title={t('nav.history-container.drawer-tittle', 'History')}
|
||||
onClose={() => {
|
||||
onToggleShowHistoryDrawer();
|
||||
//Using new tracking event process
|
||||
logUnifiedHistoryDrawerInteractionEvent({ type: 'close' });
|
||||
}}
|
||||
size="sm"
|
||||
|
||||
@@ -68,6 +68,7 @@ export function HistoryWrapper({ onClose }: { onClose: () => void }) {
|
||||
fill="text"
|
||||
onClick={() => {
|
||||
setNumItemsToShow(numItemsToShow + 5);
|
||||
//Using new tracking event process
|
||||
logUnifiedHistoryShowMoreEvent();
|
||||
}}
|
||||
>
|
||||
@@ -127,6 +128,7 @@ function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
//Using new tracking event process
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: url });
|
||||
}}
|
||||
href={url}
|
||||
@@ -188,6 +190,7 @@ function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) {
|
||||
onClick={() => {
|
||||
store.setObject('CLICKING_HISTORY', true);
|
||||
onClick();
|
||||
//Using new tracking event process
|
||||
logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' });
|
||||
}}
|
||||
isCompact={true}
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked';
|
||||
const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered';
|
||||
const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction';
|
||||
const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more';
|
||||
import { createEventFactory, TrackingEventProps } from 'app/core/services/echo/Echo';
|
||||
|
||||
//Currently just 'timeRange' is supported
|
||||
//in short term, we could add 'templateVariables' for example
|
||||
type subEntryTypes = 'timeRange';
|
||||
type SubEntryTypes = 'timeRange';
|
||||
type UnifiedHistoryDrawerActions = 'open' | 'close';
|
||||
|
||||
//Whether the user opens or closes the `HistoryDrawer`
|
||||
type UnifiedHistoryDrawerInteraction = 'open' | 'close';
|
||||
|
||||
interface UnifiedHistoryEntryClicked {
|
||||
//We will also work with the current URL but we will get this from Rudderstack data
|
||||
//URL to return to
|
||||
interface UnifiedHistoryEntryClicked extends TrackingEventProps {
|
||||
/** We will also work with the current URL but we will get this from Rudderstack data
|
||||
* URL to return to
|
||||
*/
|
||||
entryURL: string;
|
||||
//In the case we want to go back to a specific query param, currently just a specific time range
|
||||
subEntry?: subEntryTypes;
|
||||
/** In the case we want to go back to a specific query param, currently just a specific time range */
|
||||
subEntry?: SubEntryTypes;
|
||||
}
|
||||
|
||||
interface UnifiedHistoryEntryDuplicated {
|
||||
// Common name of the history entries
|
||||
interface UnifiedHistoryEntryDuplicated extends TrackingEventProps {
|
||||
/** Common name of the history entries */
|
||||
entryName: string;
|
||||
// URL of the last entry
|
||||
/** URL of the last entry */
|
||||
lastEntryURL: string;
|
||||
// URL of the new entry
|
||||
/** URL of the new entry */
|
||||
newEntryURL: string;
|
||||
}
|
||||
|
||||
//Event triggered when a user clicks on an entry of the `HistoryDrawer`
|
||||
export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, {
|
||||
entryURL,
|
||||
subEntry,
|
||||
});
|
||||
};
|
||||
interface UnifiedHistoryDrawerInteraction extends TrackingEventProps {
|
||||
/** Whether the user opens or closes the HistoryDrawer */
|
||||
type: UnifiedHistoryDrawerActions;
|
||||
}
|
||||
|
||||
//Event triggered when history entry name matches the previous one
|
||||
//so we keep track of duplicated entries and be able to analyze them
|
||||
export const logDuplicateUnifiedHistoryEntryEvent = ({
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
}: UnifiedHistoryEntryDuplicated) => {
|
||||
reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, {
|
||||
entryName,
|
||||
lastEntryURL,
|
||||
newEntryURL,
|
||||
});
|
||||
};
|
||||
const createUnifiedHistoryEvent = createEventFactory('grafana', 'unified_history');
|
||||
|
||||
//We keep track of users open and closing the drawer
|
||||
export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, {
|
||||
type,
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Event triggered when a user clicks on an entry of the `HistoryDrawer`
|
||||
* @owner grafana-frontend-platform
|
||||
*/
|
||||
export const logClickUnifiedHistoryEntryEvent = createUnifiedHistoryEvent<UnifiedHistoryEntryClicked>('entry_clicked');
|
||||
|
||||
//We keep track of users clicking on the `Show more` button
|
||||
export const logUnifiedHistoryShowMoreEvent = () => {
|
||||
reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE);
|
||||
};
|
||||
/**
|
||||
* Event triggered when history entry name matches the previous one
|
||||
* so we keep track of duplicated entries and be able to analyze them
|
||||
* @owner grafana-frontend-platform
|
||||
*/
|
||||
export const logDuplicateUnifiedHistoryEntryEvent =
|
||||
createUnifiedHistoryEvent<UnifiedHistoryEntryDuplicated>('duplicated_entry_rendered');
|
||||
|
||||
/** We keep track of users open and closing the drawer
|
||||
* @owner grafana-frontend-platform
|
||||
*/
|
||||
export const logUnifiedHistoryDrawerInteractionEvent =
|
||||
createUnifiedHistoryEvent<UnifiedHistoryDrawerInteraction>('drawer_interaction');
|
||||
|
||||
/**We keep track of users clicking on the `Show more` button
|
||||
* @owner grafana-frontend-platform
|
||||
*/
|
||||
export const logUnifiedHistoryShowMoreEvent = createUnifiedHistoryEvent('show_more');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv } from '@grafana/runtime';
|
||||
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { contextSrv } from '../context_srv';
|
||||
|
||||
@@ -90,3 +90,15 @@ export class Echo implements EchoSrv {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Analytics framework:
|
||||
* Foundational types and functions for the new tracking event process
|
||||
*/
|
||||
export type TrackingEventProps = {
|
||||
[key: string]: boolean | string | number | undefined;
|
||||
};
|
||||
export const createEventFactory = (product: string, featureName: string) => {
|
||||
return <P extends TrackingEventProps | undefined = undefined>(eventName: string) =>
|
||||
(props: P extends undefined ? void : P) =>
|
||||
reportInteraction(`${product}_${featureName}_${eventName}`, props ?? undefined);
|
||||
};
|
||||
|
||||
135
scripts/cli/analytics/eventParser.mts
Normal file
135
scripts/cli/analytics/eventParser.mts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Node, type SourceFile, type ts, type Type, type VariableStatement } from 'ts-morph';
|
||||
|
||||
import type { Event, EventNamespace, EventProperty } from './types.mts';
|
||||
import { resolveType, getMetadataFromJSDocs } from './utils/typeResolution.mts';
|
||||
|
||||
/**
|
||||
* Finds all events - calls to the function returned by createEventFactory - declared in a file
|
||||
*
|
||||
* An event feature namespace is defined by:
|
||||
* const createNavEvent = createEventFactory('grafana', 'navigation');
|
||||
*
|
||||
* Which will be used to define multiple events like:
|
||||
* interface ClickProperties {
|
||||
* linkText: string;
|
||||
* }
|
||||
* const trackClick = createNavEvent<ClickProperties>('click');
|
||||
*/
|
||||
export function parseEvents(file: SourceFile, eventNamespaces: Map<string, EventNamespace>): Event[] {
|
||||
const events: Event[] = [];
|
||||
const variableDecls = file.getVariableDeclarations();
|
||||
|
||||
for (const variableDecl of variableDecls) {
|
||||
// Get the initializer (right hand side of `=`) of the variable declaration
|
||||
// and make sure it's a function call
|
||||
const initializer = variableDecl.getInitializer();
|
||||
if (!initializer || !Node.isCallExpression(initializer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only interested in calls to functions returned by createEventFactory
|
||||
const initializerFnName = initializer.getExpression().getText();
|
||||
const eventNamespace = eventNamespaces.get(initializerFnName);
|
||||
if (!eventNamespace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Events should be defined with a single string literal argument (e.g. createNavEvent('click'))
|
||||
const [arg, ...restArgs] = initializer.getArguments();
|
||||
if (!arg || !Node.isStringLiteral(arg) || restArgs.length > 0) {
|
||||
throw new Error(`Expected ${initializerFnName} to be called with only 1 string literal argument`);
|
||||
}
|
||||
|
||||
// We're currently using the variable declaration (foo = blah), but we need the variable
|
||||
// statement (const foo = blah) to get the JSDoc nodes
|
||||
const parent = getParentVariableStatement(variableDecl);
|
||||
if (!parent) {
|
||||
throw new Error(`Parent not found for ${variableDecl.getText()}`);
|
||||
}
|
||||
|
||||
const docs = parent.getJsDocs();
|
||||
const { description, owner } = getMetadataFromJSDocs(docs); // TODO: default owner to codeowner if not found
|
||||
if (!description) {
|
||||
throw new Error(`Description not found for ${variableDecl.getText()}`);
|
||||
}
|
||||
|
||||
const eventName = arg.getLiteralText();
|
||||
const event: Event = {
|
||||
fullEventName: `${eventNamespace.eventPrefixProject}_${eventNamespace.eventPrefixFeature}_${eventName}`,
|
||||
eventProject: eventNamespace.eventPrefixProject,
|
||||
eventFeature: eventNamespace.eventPrefixFeature,
|
||||
eventName,
|
||||
|
||||
description,
|
||||
owner,
|
||||
};
|
||||
|
||||
// Get the type of the declared variable and assert it's a function
|
||||
const typeAnnotation = variableDecl.getType();
|
||||
const [callSignature, ...restCallSignatures] = typeAnnotation.getCallSignatures();
|
||||
if (callSignature === undefined || restCallSignatures.length > 0) {
|
||||
const typeAsText = typeAnnotation.getText();
|
||||
throw new Error(`Expected type to be a function with one call signature, got ${typeAsText}`);
|
||||
}
|
||||
|
||||
// The function always only have one parameter type.
|
||||
// Events that have no properties will have a void parameter type.
|
||||
const [parameter, ...restParameters] = callSignature.getParameters();
|
||||
if (parameter === undefined || restParameters.length > 0) {
|
||||
throw new Error('Expected function to have one parameter');
|
||||
}
|
||||
|
||||
// Find where the parameter type was declared and get it's type
|
||||
const parameterType = parameter.getTypeAtLocation(parameter.getDeclarations()[0]);
|
||||
|
||||
// Then describe the schema for the parameters the event function is called with
|
||||
if (parameterType.isObject()) {
|
||||
event.properties = describeObjectParameters(parameterType);
|
||||
} else if (!parameterType.isVoid()) {
|
||||
throw new Error(`Expected parameter type to be an object or void, got ${parameterType.getText()}`);
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function getParentVariableStatement(node: Node): VariableStatement | undefined {
|
||||
let parent: Node | undefined = node.getParent();
|
||||
while (parent && !Node.isVariableStatement(parent)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
if (parent && Node.isVariableStatement(parent)) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeObjectParameters(objectType: Type<ts.ObjectType>): EventProperty[] {
|
||||
const properties = objectType.getProperties().map((property) => {
|
||||
const declarations = property.getDeclarations();
|
||||
if (declarations.length !== 1) {
|
||||
throw new Error(`Expected property to have one declaration, got ${declarations.length}`);
|
||||
}
|
||||
|
||||
const declaration = declarations[0];
|
||||
const propertyType = property.getTypeAtLocation(declaration);
|
||||
const resolvedType = resolveType(propertyType);
|
||||
|
||||
if (!Node.isPropertySignature(declaration)) {
|
||||
throw new Error(`Expected property to be a property signature, got ${declaration.getKindName()}`);
|
||||
}
|
||||
|
||||
const { description } = getMetadataFromJSDocs(declaration.getJsDocs());
|
||||
return {
|
||||
name: property.getName(),
|
||||
type: resolvedType,
|
||||
description,
|
||||
};
|
||||
});
|
||||
|
||||
return properties;
|
||||
}
|
||||
104
scripts/cli/analytics/findAllEvents.mts
Normal file
104
scripts/cli/analytics/findAllEvents.mts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Event, EventNamespace } from './types.mts';
|
||||
import { parseEvents } from './eventParser.mts';
|
||||
import { type SourceFile, Node } from 'ts-morph';
|
||||
|
||||
/**
|
||||
* Finds all events - calls to the function returned by createEventFactory - declared in files
|
||||
*
|
||||
* An event feature namespace is defined by:
|
||||
* const createNavEvent = createEventFactory('grafana', 'navigation');
|
||||
*
|
||||
* Which will be used to define multiple events like:
|
||||
* interface ClickProperties {
|
||||
* linkText: string;
|
||||
* }
|
||||
* const trackClick = createNavEvent<ClickProperties>('click');
|
||||
* const trackExpand = createNavEvent('expand');
|
||||
*/
|
||||
export function findAnalyticsEvents(files: SourceFile[], createEventFactoryPath: string): Event[] {
|
||||
const allEvents: Event[] = files.flatMap((file) => {
|
||||
// Get the local imported name of createEventFactory
|
||||
const createEventFactoryImportedName = getEventFactoryFunctionName(file, createEventFactoryPath);
|
||||
if (!createEventFactoryImportedName) return [];
|
||||
|
||||
// Find all calls to createEventFactory and the namespaces they create
|
||||
const eventNamespaces = findEventNamespaces(file, createEventFactoryImportedName);
|
||||
|
||||
// Find all events defined in the file
|
||||
const events = parseEvents(file, eventNamespaces);
|
||||
return events;
|
||||
});
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the local name of the createEventFactory function imported from the given path
|
||||
*
|
||||
* @param file - The file to search for the import
|
||||
* @param createEventFactoryPath - The path to the createEventFactory function
|
||||
*/
|
||||
function getEventFactoryFunctionName(file: SourceFile, createEventFactoryPath: string): string | undefined {
|
||||
const imports = file.getImportDeclarations();
|
||||
|
||||
for (const importDeclaration of imports) {
|
||||
const namedImports = importDeclaration.getNamedImports();
|
||||
|
||||
for (const namedImport of namedImports) {
|
||||
const importName = namedImport.getName();
|
||||
|
||||
if (importName === 'createEventFactory') {
|
||||
const moduleSpecifier = importDeclaration.getModuleSpecifierSourceFile();
|
||||
if (!moduleSpecifier) continue;
|
||||
|
||||
if (moduleSpecifier.getFilePath() === createEventFactoryPath) {
|
||||
return namedImport.getAliasNode()?.getText() || importName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findEventNamespaces(file: SourceFile, createEventFactoryImportedName: string): Map<string, EventNamespace> {
|
||||
const variableDecls = file.getVariableDeclarations();
|
||||
const eventNamespaces = new Map<string, EventNamespace>();
|
||||
|
||||
for (const variableDecl of variableDecls) {
|
||||
const eventFactoryName = variableDecl.getName();
|
||||
|
||||
const initializer = variableDecl.getInitializer();
|
||||
if (!initializer) continue;
|
||||
if (!Node.isCallExpression(initializer)) continue;
|
||||
|
||||
const initializerFnName = initializer.getExpression().getText();
|
||||
if (initializerFnName !== createEventFactoryImportedName) continue;
|
||||
|
||||
const args = initializer.getArguments();
|
||||
if (args.length !== 2) {
|
||||
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 arguments`);
|
||||
}
|
||||
|
||||
const [argA, argB] = args;
|
||||
|
||||
if (!Node.isStringLiteral(argA) || !Node.isStringLiteral(argB)) {
|
||||
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 string arguments`);
|
||||
}
|
||||
|
||||
const eventPrefixRepo = argA.getLiteralText();
|
||||
const eventPrefixFeature = argB.getLiteralText();
|
||||
|
||||
console.log(
|
||||
`found where ${createEventFactoryImportedName} is called, ${eventFactoryName} = ${eventPrefixRepo}_${eventPrefixFeature}`
|
||||
);
|
||||
|
||||
eventNamespaces.set(eventFactoryName, {
|
||||
factoryName: eventFactoryName,
|
||||
eventPrefixProject: eventPrefixRepo,
|
||||
eventPrefixFeature: eventPrefixFeature,
|
||||
});
|
||||
}
|
||||
|
||||
return eventNamespaces;
|
||||
}
|
||||
25
scripts/cli/analytics/main.mts
Normal file
25
scripts/cli/analytics/main.mts
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { Project } from 'ts-morph';
|
||||
import { findAnalyticsEvents } from './findAllEvents.mts';
|
||||
import { formatEventsAsMarkdown } from './outputFormats/markdown.mts';
|
||||
|
||||
const CREATE_EVENT_FACTORY_PATH = path.resolve('public/app/core/services/echo/Echo.ts');
|
||||
const SOURCE_FILE_PATTERNS = ['**/*.ts'];
|
||||
const OUTPUT_FORMAT = 'markdown';
|
||||
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.resolve('tsconfig.json'),
|
||||
});
|
||||
const files = project.getSourceFiles(SOURCE_FILE_PATTERNS);
|
||||
|
||||
const events = findAnalyticsEvents(files, CREATE_EVENT_FACTORY_PATH);
|
||||
|
||||
if (OUTPUT_FORMAT === 'markdown') {
|
||||
const markdown = await formatEventsAsMarkdown(events);
|
||||
console.log(markdown);
|
||||
|
||||
await fs.writeFile('analytics-report.md', markdown);
|
||||
} else {
|
||||
console.log(JSON.stringify(events, null, 2));
|
||||
}
|
||||
76
scripts/cli/analytics/outputFormats/markdown.mts
Normal file
76
scripts/cli/analytics/outputFormats/markdown.mts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Event } from '../types.mts';
|
||||
import prettier from 'prettier';
|
||||
|
||||
function makeMarkdownTable(properties: Array<Record<string, string | undefined>>): string {
|
||||
const keys = Object.keys(properties[0]);
|
||||
|
||||
const header = `| ${keys.join(' | ')} |`;
|
||||
const border = `| ${keys.map((header) => '-'.padEnd(header.length, '-')).join(' | ')} |`;
|
||||
|
||||
const rows = properties.map((property) => {
|
||||
const columns = keys.map((key) => {
|
||||
const value = property[key] ?? '';
|
||||
return String(value).replace(/\|/g, '\\|');
|
||||
});
|
||||
|
||||
return '| ' + columns.join(' | ') + ' |';
|
||||
});
|
||||
|
||||
return [header, border, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export function formatEventAsMarkdown(event: Event): string {
|
||||
const preparedProperties =
|
||||
event.properties?.map((property) => {
|
||||
return {
|
||||
name: property.name,
|
||||
type: '`' + property.type + '`',
|
||||
description: property.description,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const propertiesTable = event.properties ? makeMarkdownTable(preparedProperties) : '';
|
||||
|
||||
const markdownRows = [
|
||||
`#### ${event.fullEventName}`,
|
||||
event.description,
|
||||
event.owner ? `**Owner:** ${event.owner}` : undefined,
|
||||
...(event.properties ? [`##### Properties`, propertiesTable] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
return markdownRows.join('\n\n');
|
||||
}
|
||||
|
||||
export async function formatEventsAsMarkdown(events: Event[]): Promise<string> {
|
||||
const byFeature: Record<string, Event[]> = {};
|
||||
|
||||
for (const event of events) {
|
||||
const feature = event.eventFeature;
|
||||
byFeature[feature] = byFeature[feature] ?? [];
|
||||
byFeature[feature].push(event);
|
||||
}
|
||||
|
||||
const markdownPerFeature = Object.entries(byFeature)
|
||||
.map(([feature, events]) => {
|
||||
const markdownPerEvent = events.map(formatEventAsMarkdown).join('\n');
|
||||
|
||||
return `
|
||||
### ${feature}
|
||||
|
||||
${markdownPerEvent}
|
||||
`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const markdown = `
|
||||
# Analytics report
|
||||
|
||||
This report contains all the analytics events that are defined in the project.
|
||||
|
||||
## Events
|
||||
|
||||
${markdownPerFeature}
|
||||
`;
|
||||
|
||||
return prettier.format(markdown, { parser: 'markdown' });
|
||||
}
|
||||
22
scripts/cli/analytics/types.mts
Normal file
22
scripts/cli/analytics/types.mts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface EventNamespace {
|
||||
factoryName: string;
|
||||
eventPrefixProject: string;
|
||||
eventPrefixFeature: string;
|
||||
}
|
||||
|
||||
export interface EventProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
fullEventName: string;
|
||||
eventProject: string;
|
||||
eventFeature: string;
|
||||
eventName: string;
|
||||
|
||||
description: string;
|
||||
owner?: string;
|
||||
properties?: EventProperty[];
|
||||
}
|
||||
98
scripts/cli/analytics/utils/typeResolution.mts
Normal file
98
scripts/cli/analytics/utils/typeResolution.mts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { JSDoc, Type } from 'ts-morph';
|
||||
|
||||
/**
|
||||
* Resolves a TypeScript type to a string representation. For example for:
|
||||
* type Action = "click" | "hover"
|
||||
* `Action` resolves to `"click" | "hover"`
|
||||
*
|
||||
* @param type Type to resolve
|
||||
* @returns String representation of the type
|
||||
*/
|
||||
export function resolveType(type: Type): string {
|
||||
// If the type is an alias (e.g., `Action`), resolve its declaration
|
||||
const aliasSymbol = type.getAliasSymbol();
|
||||
if (aliasSymbol) {
|
||||
const aliasType = type.getSymbol()?.getDeclarations()?.[0]?.getType();
|
||||
if (aliasType) {
|
||||
return resolveType(aliasType);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: If it's a union type, resolve each member recursively
|
||||
if (type.isUnion()) {
|
||||
return type
|
||||
.getUnionTypes()
|
||||
.map((t) => resolveType(t))
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
// Step 3: If it's a string literal type, return its literal value
|
||||
if (type.isStringLiteral()) {
|
||||
return `"${type.getLiteralValue()}"`;
|
||||
}
|
||||
|
||||
// TODO: handle enums. Would want to represent an enum as a union of its values
|
||||
// If the type is an enum, resolve it to a union of its values
|
||||
if (type.isEnum()) {
|
||||
const enumMembers = type.getSymbol()?.getDeclarations()?.[0]?.getChildren() || [];
|
||||
const values = enumMembers
|
||||
.filter((member) => member.getKindName() === 'SyntaxList' && member.getText() !== `export`)
|
||||
.map((member) => {
|
||||
const value = member.getText();
|
||||
const stripQuotesAndBackticks = value.replace(/['"`]/g, '').replace(/`/g, '');
|
||||
const splitOnCommaAndReturn = stripQuotesAndBackticks.split(',\n');
|
||||
return splitOnCommaAndReturn
|
||||
.map((v) => {
|
||||
const trimmed = v.trim().replace(/,/g, '');
|
||||
const splitOnEquals = trimmed.split('=');
|
||||
return `"${splitOnEquals[1].trim()}"`;
|
||||
})
|
||||
.join(` | `);
|
||||
});
|
||||
return values.join(` | `);
|
||||
}
|
||||
|
||||
return type.getText(); // Default to the type's text representation
|
||||
}
|
||||
|
||||
export interface JSDocMetadata {
|
||||
description?: string;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts description and owner from a JSDoc comment.
|
||||
*
|
||||
* @param docs JSDoc comment nodes to extract metadata from
|
||||
* @returns Metadata extracted from the JSDoc comments
|
||||
*/
|
||||
export function getMetadataFromJSDocs(docs: JSDoc[]): JSDocMetadata {
|
||||
let description: string | undefined;
|
||||
let owner: string | undefined;
|
||||
|
||||
if (docs.length > 1) {
|
||||
// TODO: Do we need to handle multiple JSDoc comments? Why would there be more than one?
|
||||
throw new Error('Expected only one JSDoc comment');
|
||||
}
|
||||
|
||||
for (const doc of docs) {
|
||||
const desc = trimString(doc.getDescription());
|
||||
if (desc) {
|
||||
description = desc;
|
||||
}
|
||||
|
||||
const tags = doc.getTags();
|
||||
for (const tag of tags) {
|
||||
if (tag.getTagName() === 'owner') {
|
||||
const tagText = tag.getCommentText();
|
||||
owner = tagText && trimString(tagText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { description, owner };
|
||||
}
|
||||
|
||||
function trimString(str: string): string {
|
||||
return str.trim().replace(/\n/g, ' ');
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "commonjs"
|
||||
"module": "es2022",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"extends": "../../tsconfig.json",
|
||||
"ts-node": {
|
||||
|
||||
60
yarn.lock
60
yarn.lock
@@ -9823,6 +9823,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@ts-morph/common@npm:~0.28.1":
|
||||
version: 0.28.1
|
||||
resolution: "@ts-morph/common@npm:0.28.1"
|
||||
dependencies:
|
||||
minimatch: "npm:^10.0.1"
|
||||
path-browserify: "npm:^1.0.1"
|
||||
tinyglobby: "npm:^0.2.14"
|
||||
checksum: 10/d5c6ed11cf046c186c7263c28c7e9b5fbefb61c65b99f66cfe6a3b249f70f3fbf116b5aace2980602a7ceabecdc399065d5a7f14aabe5475eb43fd573a3cc665
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tsconfig/node10@npm:^1.0.7":
|
||||
version: 1.0.8
|
||||
resolution: "@tsconfig/node10@npm:1.0.8"
|
||||
@@ -14233,6 +14244,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"code-block-writer@npm:^13.0.3":
|
||||
version: 13.0.3
|
||||
resolution: "code-block-writer@npm:13.0.3"
|
||||
checksum: 10/771546224f38610eecee0598e83c9e0f86dcd600ea316dbf27c2cfebaab4fed51b042325aa460b8e0f131fac5c1de208f6610a1ddbffe4b22e76f9b5256707cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"code-point-at@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "code-point-at@npm:1.1.0"
|
||||
@@ -18121,6 +18139,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fdir@npm:^6.5.0":
|
||||
version: 6.5.0
|
||||
resolution: "fdir@npm:6.5.0"
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fflate@npm:^0.8.2":
|
||||
version: 0.8.2
|
||||
resolution: "fflate@npm:0.8.2"
|
||||
@@ -19735,6 +19765,7 @@ __metadata:
|
||||
testing-library-selector: "npm:0.3.1"
|
||||
tracelib: "npm:1.0.1"
|
||||
ts-jest: "npm:29.4.0"
|
||||
ts-morph: "npm:^27.0.2"
|
||||
ts-node: "npm:10.9.2"
|
||||
tslib: "npm:2.8.1"
|
||||
tween-functions: "npm:^1.2.0"
|
||||
@@ -24295,7 +24326,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:10.1.1, minimatch@npm:^10.1.1":
|
||||
"minimatch@npm:10.1.1, minimatch@npm:^10.0.1, minimatch@npm:^10.1.1":
|
||||
version: 10.1.1
|
||||
resolution: "minimatch@npm:10.1.1"
|
||||
dependencies:
|
||||
@@ -26908,6 +26939,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "picomatch@npm:4.0.3"
|
||||
checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pify@npm:5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "pify@npm:5.0.0"
|
||||
@@ -32344,6 +32382,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.14":
|
||||
version: 0.2.15
|
||||
resolution: "tinyglobby@npm:0.2.15"
|
||||
dependencies:
|
||||
fdir: "npm:^6.5.0"
|
||||
picomatch: "npm:^4.0.3"
|
||||
checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyqueue@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "tinyqueue@npm:3.0.0"
|
||||
@@ -32714,6 +32762,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-morph@npm:^27.0.2":
|
||||
version: 27.0.2
|
||||
resolution: "ts-morph@npm:27.0.2"
|
||||
dependencies:
|
||||
"@ts-morph/common": "npm:~0.28.1"
|
||||
code-block-writer: "npm:^13.0.3"
|
||||
checksum: 10/b9bd8ed86d4b76ca23446d3f808787cfe95a3bcb2316769a2f3fa262ea9ab4043bb76bf8aef677f9be0d758576f074df2926e701a535f84fd21946fdbf465148
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-node@npm:10.9.2, ts-node@npm:^10.9.1":
|
||||
version: 10.9.2
|
||||
resolution: "ts-node@npm:10.9.2"
|
||||
|
||||
Reference in New Issue
Block a user