Files
grafana/scripts/cli/analytics/eventParser.mts
2025-11-27 00:37:17 +01:00

135 lines
5.1 KiB
TypeScript

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