From 80c7d17543a8bd3e2410e731dab00efa1a94bbfa Mon Sep 17 00:00:00 2001 From: eledobleefe Date: Thu, 27 Nov 2025 00:37:17 +0100 Subject: [PATCH] Copy analytics frameworks code --- .../grafana-runtime/src/analytics/utils.ts | 2 +- public/app/core/services/echo/Echo.ts | 14 +- scripts/cli/analytics/eventParser.mts | 135 ++++++++++++++++++ scripts/cli/analytics/findAllEvents.mts | 104 ++++++++++++++ scripts/cli/analytics/main.mts | 25 ++++ .../cli/analytics/outputFormats/markdown.mts | 76 ++++++++++ scripts/cli/analytics/types.mts | 22 +++ .../cli/analytics/utils/typeResolution.mts | 98 +++++++++++++ scripts/cli/tsconfig.json | 4 +- 9 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 scripts/cli/analytics/eventParser.mts create mode 100644 scripts/cli/analytics/findAllEvents.mts create mode 100644 scripts/cli/analytics/main.mts create mode 100644 scripts/cli/analytics/outputFormats/markdown.mts create mode 100644 scripts/cli/analytics/types.mts create mode 100644 scripts/cli/analytics/utils/typeResolution.mts diff --git a/packages/grafana-runtime/src/analytics/utils.ts b/packages/grafana-runtime/src/analytics/utils.ts index a5363420e74..a6931928c5a 100644 --- a/packages/grafana-runtime/src/analytics/utils.ts +++ b/packages/grafana-runtime/src/analytics/utils.ts @@ -43,7 +43,7 @@ export const reportPageview = () => { * * @public */ -export const reportInteraction = (interactionName: string, properties?: Record) => { +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 }; diff --git a/public/app/core/services/echo/Echo.ts b/public/app/core/services/echo/Echo.ts index baa22c22aa4..abdf35c0619 100644 --- a/public/app/core/services/echo/Echo.ts +++ b/public/app/core/services/echo/Echo.ts @@ -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

(eventName: string) => + (props: P extends undefined ? void : P) => + reportInteraction(`${product}_${featureName}_${eventName}`, props ?? undefined); +}; diff --git a/scripts/cli/analytics/eventParser.mts b/scripts/cli/analytics/eventParser.mts new file mode 100644 index 00000000000..17b07f3bea4 --- /dev/null +++ b/scripts/cli/analytics/eventParser.mts @@ -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('click'); + */ +export function parseEvents(file: SourceFile, eventNamespaces: Map): 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): 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; +} \ No newline at end of file diff --git a/scripts/cli/analytics/findAllEvents.mts b/scripts/cli/analytics/findAllEvents.mts new file mode 100644 index 00000000000..4081d54c2ac --- /dev/null +++ b/scripts/cli/analytics/findAllEvents.mts @@ -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('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 { + const variableDecls = file.getVariableDeclarations(); + const eventNamespaces = new Map(); + + 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; +} \ No newline at end of file diff --git a/scripts/cli/analytics/main.mts b/scripts/cli/analytics/main.mts new file mode 100644 index 00000000000..daa8e100934 --- /dev/null +++ b/scripts/cli/analytics/main.mts @@ -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)); +} \ No newline at end of file diff --git a/scripts/cli/analytics/outputFormats/markdown.mts b/scripts/cli/analytics/outputFormats/markdown.mts new file mode 100644 index 00000000000..95286073484 --- /dev/null +++ b/scripts/cli/analytics/outputFormats/markdown.mts @@ -0,0 +1,76 @@ +import type { Event } from '../types.mts'; +import prettier from 'prettier'; + +function makeMarkdownTable(properties: Array>): 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 { + const byFeature: Record = {}; + + 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' }); +} \ No newline at end of file diff --git a/scripts/cli/analytics/types.mts b/scripts/cli/analytics/types.mts new file mode 100644 index 00000000000..cdadbf1f2b5 --- /dev/null +++ b/scripts/cli/analytics/types.mts @@ -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[]; +} \ No newline at end of file diff --git a/scripts/cli/analytics/utils/typeResolution.mts b/scripts/cli/analytics/utils/typeResolution.mts new file mode 100644 index 00000000000..e338793ba51 --- /dev/null +++ b/scripts/cli/analytics/utils/typeResolution.mts @@ -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, ' '); +} \ No newline at end of file diff --git a/scripts/cli/tsconfig.json b/scripts/cli/tsconfig.json index 468d9938751..9ce624c8451 100644 --- a/scripts/cli/tsconfig.json +++ b/scripts/cli/tsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "moduleResolution": "node", - "module": "commonjs" + "module": "es2022", + "allowImportingTsExtensions": true, + "noEmit": true }, "extends": "../../tsconfig.json", "ts-node": {