Compare commits

...

8 Commits

Author SHA1 Message Date
eledobleefe
fc49c8d47a POC real example 2025-12-09 11:42:41 +01:00
eledobleefe
5c17e4e05e Run prettier 2025-12-09 11:00:03 +01:00
eledobleefe
bdad692470 Merge branch 'main' into eledobleefe/analytics-framework-user-test 2025-12-09 10:18:04 +01:00
eledobleefe
61097047ff Add command to run the script 2025-11-27 16:41:56 +01:00
eledobleefe
5b234a251e Merge branch 'main' into eledobleefe/analytics-framework-user-test 2025-11-27 15:55:56 +01:00
eledobleefe
9acafa2b50 Add rules 2025-11-27 01:14:05 +01:00
eledobleefe
80c7d17543 Copy analytics frameworks code 2025-11-27 00:37:17 +01:00
eledobleefe
9a7a05be50 Install ts-morph 2025-11-27 00:33:34 +01:00
18 changed files with 766 additions and 53 deletions

View File

@@ -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",

View File

@@ -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');
```

View File

@@ -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,
},
};

View 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;

View File

@@ -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 };

View File

@@ -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,

View File

@@ -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"

View File

@@ -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}

View File

@@ -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');

View File

@@ -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);
};

View 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;
}

View 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;
}

View 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));
}

View 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' });
}

View 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[];
}

View 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, ' ');
}

View File

@@ -1,7 +1,9 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "commonjs"
"module": "es2022",
"allowImportingTsExtensions": true,
"noEmit": true
},
"extends": "../../tsconfig.json",
"ts-node": {

View File

@@ -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"