Copy analytics frameworks code
This commit is contained in:
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": {
|
||||
|
||||
Reference in New Issue
Block a user