Repository: grafana/synthetic-monitoring-app
Pull request #1119: Feat/event tracking doc gen
Author: login: ckbedwell
Created: 2025-04-15T15:16:56Z | Merged: 2025-04-28T13:07:16Z

## Problem

When we add analytics events to the codebase they are difficult to discover for non-developers and others who might want a quick reference of what is available and what properties they provide when utilising Rudderstack and the databases which the data gets piped to.

Despite having a convention  for how we want to handle analytics events (such as putting all event generation in `features/tracking/{FEATURE_NAME}` directories), this still requires a certain level of know-how and a dependency on developers following the convention.

Manual documentation faces the problem where it can easily become out of date as we add and edit existing events.

## Solution

_This solution is based on the [Hackathon 12 project](https://github.com/grafana/grafana/pull/101697) in `grafana/grafana`._

This PR adds several pieces to automate a solution to the problems above which enforces developers to document their analytics events inline, reducing developer friction whilst keeping high quality and accurate analytics documentation.

1. Introduces a custom eslint plugin with its own custom rule which has been defined. This rule does several things:
  - Checks that `createSMEventFactory` has been imported into any files, and if it has:
  - It checks any declared interfaces extend `TrackingEventProps`
  - Those interfaces have JSDoc comments for their properties
  - It also ensures that `createSMEventFactory` is called with only literals and doesn't contain any variables which might create high cardinality events.
2. Adds an eslint rule which forbids the use of `reportInteraction` being used directly.
3. Adds a node script (which can be run using `yarn analytics-events:generate` which creates/updates our `analytics-events.md` file. By using the conventions enforced by the custom eslint rule it ensures the documentation is an accurate representation of what analytics events are in the codebase.

## Open questions

Currently, we have to run the script ourselves to keep our analytics events up to date. Has anyone got suggestions at what point during the CI/CD process we should run it automatically so we don't have to remember to run it?


Diff:
diff --git a/docs/analytics/analytics-events.md b/docs/analytics/analytics-events.md
new file mode 100644
index 000000000..0e3423a47
--- /dev/null
+++ b/docs/analytics/analytics-events.md
@@ -0,0 +1,85 @@
+# Analytics Events
+
+_This document is generated automatically and should not be edited directly. To see how to generate this document, see [docs/analytics/analytics.md](./analytics.md#generating-the-analytics-eventsmd-file)._
+
+This document contains all the analytics events that are defined in the project.
+
+## Events
+
+### check_creation
+
+#### synthetic-monitoring_check_creation_add_new_check_button_clicked
+
+Tracks when the "Add New Check" button is clicked.
+
+##### Properties
+
+| name   | type                         | description                                |
+| ------ | ---------------------------- | ------------------------------------------ |
+| source | `"check-list" \| "homepage"` | What location the button was clicked from. |
+
+#### synthetic-monitoring_check_creation_add_check_type_group_button_clicked
+
+Tracks when the Primary Button of the check type card is clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+
+#### synthetic-monitoring_check_creation_add_check_type_button_clicked
+
+Tracks when the 'protocol' buttons on the check type card are clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+| protocol       | `string`                                                   | The protocol of the check.         |
+
+### check_form
+
+#### synthetic-monitoring_check_form_navigate_wizard_form_button_clicked
+
+Tracks navigation events within the check form wizard.
+
+##### Properties
+
+| name      | type                                                                                                     | description                                     |
+| --------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
+| checkType | `"browser" \| "dns" \| "grpc" \| "http" \| "multihttp" \| "ping" \| "scripted" \| "tcp" \| "traceroute"` | The type of check.                              |
+| step      | `"job" \| "uptime" \| "labels" \| "alerting" \| "execution"`                                             | The current step in the wizard.                 |
+| component | `"forward-button" \| "back-button" \| "stepper"`                                                         | The UI component that triggered the navigation. |
+
+#### synthetic-monitoring_check_form_adhoc_test_created
+
+Tracks when an adhoc test is successfully created.
+
+##### Properties
+
+| name       | type                                                                                                     | description                           |
+| ---------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------- |
+| checkType  | `"browser" \| "dns" \| "grpc" \| "http" \| "multihttp" \| "ping" \| "scripted" \| "tcp" \| "traceroute"` | The type of check.                    |
+| checkState | `"new" \| "existing"`                                                                                    | Whether the check is new or existing. |
+
+#### synthetic-monitoring_check_form_check_created
+
+Tracks when a check is successfully created.
+
+##### Properties
+
+| name      | type                                                                                                     | description        |
+| --------- | -------------------------------------------------------------------------------------------------------- | ------------------ |
+| checkType | `"browser" \| "dns" \| "grpc" \| "http" \| "multihttp" \| "ping" \| "scripted" \| "tcp" \| "traceroute"` | The type of check. |
+
+#### synthetic-monitoring_check_form_check_updated
+
+Tracks when a check is successfully updated.
+
+##### Properties
+
+| name      | type                                                                                                     | description        |
+| --------- | -------------------------------------------------------------------------------------------------------- | ------------------ |
+| checkType | `"browser" \| "dns" \| "grpc" \| "http" \| "multihttp" \| "ping" \| "scripted" \| "tcp" \| "traceroute"` | The type of check. |
diff --git a/docs/analytics/analytics.md b/docs/analytics/analytics.md
new file mode 100644
index 000000000..bccf4a951
--- /dev/null
+++ b/docs/analytics/analytics.md
@@ -0,0 +1,48 @@
+# Analytics
+
+For the continued success of Synthetic Monitoring, we need to understand how our users are using the product. We do this through a mixture of explicit and implicit event tracking. For explicit event tracking we use __Rudderstack__ and for implicit event tracking we use __FullStory__.
+
+## How to add an explicit event
+
+_This framework is based on the Grafana 12 hackathon project which can be [found here](https://github.com/grafana/grafana/pull/101697). Steps are being taken to expose the `createEventFactory` function in the `@grafana/runtime` package in the future so this is both an effort to standardise our events and a stop gap until that happens._
+
+The `@grafana/runtime` package provides a `reportInteraction` function which should __not__ be used directly. You will receive a lint error if you try to use it.
+
+Instead, you should use the `createSMEventFactory` function to create an event factory for your feature. It is a wrapper around `createEventFactory` (which is what is likely to be exposed in the `@grafana/runtime` package in the future) so our events are standardised and follow a consistent pattern:
+
+```md
+synthetic-monitoring_{FEATURE_NAME}_{EVENT_NAME}
+```
+
+Where `{FEATURE_NAME}` is the name of the feature and `{EVENT_NAME}` is the name of the event. You can find the list of all events across the whole codebase in the [analytics-events.md](./analytics-events.md) file.
+
+For each feature you wish to track, create a new file in the `src/features/tracking` directory and add the event factory to it.
+
+### Event naming convention
+
+1. Use snake_case for the feature and event names
+2. Be clear when the event being tracked is a UI component interaction (such as `button_clicked`) or the result of a user action (such as `check_created` or `check_updated`)
+3. Avoid overly long feature and event names
+4. Do not use variables or any other high cardinality properties in the feature or event name. The eslint rule is configured to catch this.
+
+### Generating the analytics-events.md file
+
+To generate the `analytics-events.md` file, run the following command:
+
+```bash
+yarn analytics-events:generate
+```
+
+This will generate the `analytics-events.md` file in the `docs/analytics` directory. Do not edit it directly - if it needs to be updated, edit the events and their JSDoc comments then run the command again.
+
+## Instrumenting implicit events
+
+Our implicit event tracking is done through FullStory. It uses `data-fs-element` data attributes and the full documentation can be found [here](https://developer.fullstory.com/mobile/android/fullcapture/set-element-properties/).
+
+
+
+
+
+
+
+
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 062f6040f..2f58ffedc 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -5,6 +5,7 @@ import { FlatCompat } from '@eslint/eslintrc';
 import path from 'path';
 import { fileURLToPath } from 'url';
 import tsParser from '@typescript-eslint/parser';
+import customPlugin from './eslintCustom/plugin.custom.js';
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
@@ -20,8 +21,10 @@ const config = defineConfig([
     plugins: {
       'simple-import-sort': simpleImportSort,
       '@tanstack/eslint-plugin-query': queryPlugin,
+      'custom-plugin': customPlugin,
     },
     rules: {
+      'custom-plugin/tracking-event-creation': 'error',
       'no-console': ['error', { allow: [''] }],
       'no-redeclare': 'off', // we use typescript's 'no-redeclare' rule instead
       '@typescript-eslint/no-redeclare': ['error'],
@@ -33,6 +36,18 @@ const config = defineConfig([
           message: 'Prefer named exports',
         },
       ],
+      'no-restricted-imports': [
+        'error',
+        {
+          paths: [
+            {
+              name: '@grafana/runtime',
+              importNames: ['reportInteraction'],
+              message: 'Please use createSMEventFactory to create an event factory',
+            },
+          ],
+        },
+      ],
       ['simple-import-sort/imports']: [
         'error',
         {
diff --git a/eslintCustom/plugin.custom.js b/eslintCustom/plugin.custom.js
new file mode 100644
index 000000000..91b9ec83e
--- /dev/null
+++ b/eslintCustom/plugin.custom.js
@@ -0,0 +1,8 @@
+const trackingEventCreationRule = require('./rule.trackingEventCreation');
+
+const plugin = {
+  rules: {
+    'tracking-event-creation': trackingEventCreationRule,
+  },
+};
+module.exports = plugin;
diff --git a/eslintCustom/rule.trackingEventCreation.js b/eslintCustom/rule.trackingEventCreation.js
new file mode 100644
index 000000000..b4e5055c8
--- /dev/null
+++ b/eslintCustom/rule.trackingEventCreation.js
@@ -0,0 +1,162 @@
+// @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 createSMEventFactory is imported as
+    let createSMEventFactoryName = 'createSMEventFactory';
+    // Track if createSMEventFactory is imported
+    let iscreateSMEventFactoryImported = false;
+    // Track variables that store createSMEventFactory calls
+    const eventFactoryVariables = new Set();
+
+    return {
+      ImportSpecifier(node) {
+        if (node.imported.type === AST_NODE_TYPES.Identifier && node.imported.name === 'createSMEventFactory') {
+          // Remember what name it was imported as (handles aliased imports)
+          createSMEventFactoryName = node.local.name;
+          iscreateSMEventFactoryImported = true;
+        }
+      },
+      VariableDeclarator(node) {
+        if (!iscreateSMEventFactoryImported) {
+          return;
+        }
+
+        // Track variables initialized with createSMEventFactory calls
+        if (
+          node.init?.type === AST_NODE_TYPES.CallExpression &&
+          node.init.callee.type === AST_NODE_TYPES.Identifier &&
+          (node.init.callee.name === createSMEventFactoryName || eventFactoryVariables.has(node.init.callee.name))
+        ) {
+          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 (!iscreateSMEventFactoryImported) {
+          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
+            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 (!iscreateSMEventFactoryImported) {
+          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 (!iscreateSMEventFactoryImported) {
+          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 `createSMEventFactory` 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;
diff --git a/package.json b/package.json
index 2e4ff78e6..d4799295b 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
   "version": "1.25.0",
   "description": "Grafana Synthetic Monitoring App",
   "scripts": {
+    "analytics-events:generate": "node --experimental-strip-types ./scripts/analytics/main.mts",
     "analyze:bundle": "ANALYZE_BUNDLE=true yarn build",
     "build": "webpack -c webpack.config.ts --env production",
     "danger:ci": "yarn danger ci --dangerfile danger/dangerfile.ts",
@@ -92,6 +93,7 @@
     "styled-components": "^6.1.0",
     "swc-loader": "^0.2.3",
     "terser-webpack-plugin": "^5.3.10",
+    "ts-morph": "^25.0.1",
     "ts-node": "^10.9.2",
     "tsconfig-paths": "^4.2.0",
     "tslib": "2.5.3",
diff --git a/scripts/analytics/eventParser.mts b/scripts/analytics/eventParser.mts
new file mode 100644
index 000000000..98a360689
--- /dev/null
+++ b/scripts/analytics/eventParser.mts
@@ -0,0 +1,145 @@
+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 createSMEventFactory - declared in a file
+ *
+ * An event feature namespace is defined by:
+ *   const createNavEvent = createSMEventFactory('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 its type
+    const parameterType = parameter.getTypeAtLocation(parameter.getDeclarations()[0]);
+
+    // Then describe the schema for the parameters the event function is called with
+    if (parameterType.isObject() || parameterType.isIntersection()) {
+      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 | ts.IntersectionType>): 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)) {
+      const { description } = getMetadataFromJSDocs(declaration.getJsDocs());
+      console.log(description, property.getName(), resolvedType);
+
+      return {
+        name: property.getName(),
+        type: resolvedType,
+        description,
+      };
+    }
+
+    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;
+}
diff --git a/scripts/analytics/findAllEvents.mts b/scripts/analytics/findAllEvents.mts
new file mode 100644
index 000000000..6e4cdbe3e
--- /dev/null
+++ b/scripts/analytics/findAllEvents.mts
@@ -0,0 +1,101 @@
+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 === 'createSMEventFactory') {
+        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 !== 1) {
+      throw new Error(`Expected ${createEventFactoryImportedName} to have 1 argument`);
+    }
+
+    const [argA] = args;
+
+    if (!Node.isStringLiteral(argA)) {
+      throw new Error(`Expected ${createEventFactoryImportedName} to have 1 string argument`);
+    }
+
+    const eventPrefixRepo = `synthetic-monitoring`;
+    const eventPrefixFeature = argA.getLiteralText();
+
+    eventNamespaces.set(eventFactoryName, {
+      factoryName: eventFactoryName,
+      eventPrefixProject: eventPrefixRepo,
+      eventPrefixFeature: eventPrefixFeature,
+    });
+  }
+
+  return eventNamespaces;
+}
diff --git a/scripts/analytics/main.mts b/scripts/analytics/main.mts
new file mode 100644
index 000000000..ecd2e4ede
--- /dev/null
+++ b/scripts/analytics/main.mts
@@ -0,0 +1,26 @@
+import fs from 'fs/promises';
+import path from 'path';
+import { Project } from 'ts-morph';
+
+import { formatEventsAsMarkdown } from './outputFormats/markdown.mts';
+import { findAnalyticsEvents } from './findAllEvents.mts';
+
+const CREATE_EVENT_FACTORY_PATH = path.resolve('src/features/tracking/utils.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('docs/analytics/analytics-events.md', markdown);
+} else {
+  console.log(JSON.stringify(events, null, 2));
+}
diff --git a/scripts/analytics/outputFormats/markdown.mts b/scripts/analytics/outputFormats/markdown.mts
new file mode 100644
index 000000000..331b0e9d7
--- /dev/null
+++ b/scripts/analytics/outputFormats/markdown.mts
@@ -0,0 +1,78 @@
+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 Events
+
+_This document is generated automatically and should not be edited directly. To see how to generate this document, see [docs/analytics/analytics.md](./analytics.md#generating-the-analytics-eventsmd-file)._
+
+This document contains all the analytics events that are defined in the project.
+
+## Events
+
+${markdownPerFeature}
+`;
+
+  return prettier.format(markdown, { parser: 'markdown' });
+}
diff --git a/scripts/analytics/tsconfig.json b/scripts/analytics/tsconfig.json
new file mode 100644
index 000000000..5d45821eb
--- /dev/null
+++ b/scripts/analytics/tsconfig.json
@@ -0,0 +1,18 @@
+{
+  "compilerOptions": {
+    "moduleResolution": "node",
+    "module": "es2022",
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "rootDir": "."
+  },
+  "extends": "../../.config/tsconfig.json",
+  "include": ["./**/*.ts", "./**/*.mts"],
+  "ts-node": {
+    "transpileOnly": true,
+    "swc": true,
+    "compilerOptions": {
+      "module": "commonjs"
+    }
+  }
+}
diff --git a/scripts/analytics/types.mts b/scripts/analytics/types.mts
new file mode 100644
index 000000000..4862c6802
--- /dev/null
+++ b/scripts/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[];
+}
diff --git a/scripts/analytics/utils/typeResolution.mts b/scripts/analytics/utils/typeResolution.mts
new file mode 100644
index 000000000..317749fa9
--- /dev/null
+++ b/scripts/analytics/utils/typeResolution.mts
@@ -0,0 +1,107 @@
+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
+ * @param visitedTypes  Set of already visited types to prevent infinite recursion
+ * @returns String representation of the type
+ */
+export function resolveType(type: Type, visitedTypes = new Set<string>()): string {
+  const typeId = type.getSymbol()?.getName() || type.getText();
+  if (visitedTypes.has(typeId)) {
+    return typeId; // Return the type name if we've seen it before
+  }
+  visitedTypes.add(typeId);
+
+  // 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(` | `);
+  }
+
+  // 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, visitedTypes);
+    }
+  }
+
+  // Step 2: If it's a union type, resolve each member recursively
+  if (type.isUnion()) {
+    return type
+      .getUnionTypes()
+      .map((t) => resolveType(t, visitedTypes))
+      .join(' | ');
+  }
+
+  // Step 3: If it's a string literal type, return its literal value
+  if (type.isStringLiteral()) {
+    return `"${type.getLiteralValue()}"`;
+  }
+
+  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, ' ');
+}
diff --git a/src/features/tracking/checkCreationEvents.ts b/src/features/tracking/checkCreationEvents.ts
index c72bbf830..86ce8d731 100644
--- a/src/features/tracking/checkCreationEvents.ts
+++ b/src/features/tracking/checkCreationEvents.ts
@@ -5,25 +5,32 @@ import { CheckTypeGroup } from 'types';
 const checkCreationEvents = createSMEventFactory('check_creation');
 
 interface AddNewCheckButtonClicked extends TrackingEventProps {
+  /** What location the button was clicked from. */
   source: 'check-list' | 'homepage';
 }
 
+/** Tracks when the "Add New Check" button is clicked. */
 export const trackAddNewCheckButtonClicked =
   checkCreationEvents<AddNewCheckButtonClicked>('add_new_check_button_clicked');
 
 interface AddCheckTypeGroupButtonClicked extends TrackingEventProps {
+  /** The check group type of the check. */
   checkTypeGroup: CheckTypeGroup;
 }
 
+/** Tracks when the Primary Button of the check type card is clicked. */
 export const trackAddCheckTypeGroupButtonClicked = checkCreationEvents<AddCheckTypeGroupButtonClicked>(
   'add_check_type_group_button_clicked'
 );
 
 interface AddCheckTypeButtonClicked extends TrackingEventProps {
+  /** The check group type of the check. */
   checkTypeGroup: CheckTypeGroup;
+  /** The protocol of the check. */
   protocol: string;
 }
 
+/** Tracks when the 'protocol' buttons on the check type card are clicked. */
 export const trackAddCheckTypeButtonClicked = checkCreationEvents<AddCheckTypeButtonClicked>(
   'add_check_type_button_clicked'
 );
diff --git a/src/features/tracking/checkFormEvents.ts b/src/features/tracking/checkFormEvents.ts
index c839db108..480ac30dc 100644
--- a/src/features/tracking/checkFormEvents.ts
+++ b/src/features/tracking/checkFormEvents.ts
@@ -6,24 +6,34 @@ import { ANALYTICS_STEP_MAP } from 'components/CheckForm/FormLayout/FormLayout.c
 const checkFormEvents = createSMEventFactory('check_form');
 
 interface NavigateWizardForm extends TrackingEventProps {
+  /** The type of check. */
   checkType: CheckType;
+  /** The current step in the wizard. */
   step: (typeof ANALYTICS_STEP_MAP)[number];
+  /** The UI component that triggered the navigation. */
   component: `forward-button` | `back-button` | `stepper`;
 }
 
+/** Tracks navigation events within the check form wizard. */
 export const trackNavigateWizardForm = checkFormEvents<NavigateWizardForm>('navigate_wizard_form_button_clicked');
 
 interface AdhocCheckEvent extends TrackingEventProps {
+  /** The type of check. */
   checkType: CheckType;
+  /** Whether the check is new or existing. */
   checkState: `new` | `existing`;
 }
 
+/** Tracks when an adhoc test is successfully created. */
 export const trackAdhocCreated = checkFormEvents<AdhocCheckEvent>('adhoc_test_created');
 
 interface CheckFormEvent extends TrackingEventProps {
+  /** The type of check. */
   checkType: CheckType;
 }
 
+/** Tracks when a check is successfully created. */
 export const trackCheckCreated = checkFormEvents<CheckFormEvent>('check_created');
 
+/** Tracks when a check is successfully updated. */
 export const trackCheckUpdated = checkFormEvents<CheckFormEvent>('check_updated');
diff --git a/src/features/tracking/utils.ts b/src/features/tracking/utils.ts
index 28911749c..23c98d0a5 100644
--- a/src/features/tracking/utils.ts
+++ b/src/features/tracking/utils.ts
@@ -1,3 +1,4 @@
+// eslint-disable-next-line no-restricted-imports
 import { reportInteraction } from '@grafana/runtime';
 
 export type TrackingEventProps = {
diff --git a/yarn.lock b/yarn.lock
index 736bd32ff..4f1e68194 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2502,6 +2502,15 @@
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
   integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
 
+"@ts-morph/common@~0.26.0":
+  version "0.26.1"
+  resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.26.1.tgz#a346188e59d2befe4c71f66a7d626d81b1abe2a8"
+  integrity sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==
+  dependencies:
+    fast-glob "^3.3.2"
+    minimatch "^9.0.4"
+    path-browserify "^1.0.1"
+
 "@tsconfig/node10@^1.0.7":
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@@ -4160,6 +4169,11 @@ co@^4.6.0:
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
   integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
 
+code-block-writer@^13.0.3:
+  version "13.0.3"
+  resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b"
+  integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==
+
 collect-v8-coverage@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9"
@@ -9316,6 +9330,11 @@ parse5@^7.0.0, parse5@^7.1.1:
   dependencies:
     entities "^4.5.0"
 
+path-browserify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
+  integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
+
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
@@ -11799,6 +11818,14 @@ ts-easing@^0.2.0:
   resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec"
   integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==
 
+ts-morph@^25.0.1:
+  version "25.0.1"
+  resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-25.0.1.tgz#7de0b60fcc6e86955c8766831bcd2c5d87ffbd4f"
+  integrity sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==
+  dependencies:
+    "@ts-morph/common" "~0.26.0"
+    code-block-writer "^13.0.3"
+
 ts-node@^10.9.2:
   version "10.9.2"
   resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"


Reviews:
user: login: VikaCep
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T18:44:11Z
user: login: VikaCep
body: Works mint! 💪 💪 

Made a small comment about tightening up the `protocol` type in `AddCheckTypeButtonClicked` interface. Right now it's just a string, but we could make it more specific. 

For the analytics docs automation, I was thinking we could:
- Add a pre-commit hook that checks if there are changes and re-generates the file if so (maybe optional)
- Run a task that, before building, compares the generated docs with the committed docs and fails if there are differences.

How does that sound?
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T19:50:06Z
user: login: ckbedwell
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:59:24Z
user: login: VikaCep
body: 👏 👏 👏 

> I have a bit of reluctance adding a pre-commit hook because they inevitably get turned off or ignored at some point as they make a fairly large difference to the dev experience despite thinking 'what's a few seconds per commit?'. I think your second idea would work, maybe adding it as a GitHub workflow action? It would essentially be a lint check.

Yeah, I guess the commit hook could get annoying as it will make things slower and won't be needed most times. Agree on the Github action 👍 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T15:12:10Z

Review comments:
diff_hunk: @@ -0,0 +1,85 @@
+# Analytics Events
+
+_This document is generated automatically and should not be edited directly. To see how to generate this document, see [docs/analytics/analytics.md](./analytics.md#generating-the-analytics-eventsmd-file)._
+
+This document contains all the analytics events that are defined in the project.
+
+## Events
+
+### check_creation
+
+#### synthetic-monitoring_check_creation_add_new_check_button_clicked
+
+Tracks when the "Add New Check" button is clicked.
+
+##### Properties
+
+| name   | type                         | description                                |
+| ------ | ---------------------------- | ------------------------------------------ |
+| source | `"check-list" \| "homepage"` | What location the button was clicked from. |
+
+#### synthetic-monitoring_check_creation_add_check_type_group_button_clicked
+
+Tracks when the Primary Button of the check type card is clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+
+#### synthetic-monitoring_check_creation_add_check_type_button_clicked
+
+Tracks when the 'protocol' buttons on the check type card are clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+| protocol       | `string`                                                   | The protocol of the check.         |
path: docs/analytics/analytics-events.md
user: login: VikaCep
body: Shouldn't the protocol here be typed as `CheckType` rather than just `string`?

created_at: 2025-04-16T18:44:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,85 @@
+# Analytics Events
+
+_This document is generated automatically and should not be edited directly. To see how to generate this document, see [docs/analytics/analytics.md](./analytics.md#generating-the-analytics-eventsmd-file)._
+
+This document contains all the analytics events that are defined in the project.
+
+## Events
+
+### check_creation
+
+#### synthetic-monitoring_check_creation_add_new_check_button_clicked
+
+Tracks when the "Add New Check" button is clicked.
+
+##### Properties
+
+| name   | type                         | description                                |
+| ------ | ---------------------------- | ------------------------------------------ |
+| source | `"check-list" \| "homepage"` | What location the button was clicked from. |
+
+#### synthetic-monitoring_check_creation_add_check_type_group_button_clicked
+
+Tracks when the Primary Button of the check type card is clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+
+#### synthetic-monitoring_check_creation_add_check_type_button_clicked
+
+Tracks when the 'protocol' buttons on the check type card are clicked.
+
+##### Properties
+
+| name           | type                                                       | description                        |
+| -------------- | ---------------------------------------------------------- | ---------------------------------- |
+| checkTypeGroup | `"api-endpoint" \| "multistep" \| "scripted" \| "browser"` | The check group type of the check. |
+| protocol       | `string`                                                   | The protocol of the check.         |
path: docs/analytics/analytics-events.md
user: login: ckbedwell
body: I thought the same and is how I originally had it but realised that didn't make sense for anything that isn't an api-endpoint check. The api-endpoint ones happen to match up with most of our check-types but for the others not so much, especially as we hopefully will add to the browser and scripted ones, too.
created_at: 2025-04-17T07:59:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: CLAassistant
created_at: 2025-04-15T15:17:04Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/grafana/synthetic-monitoring-app?pullRequest=1119) <br/>All committers have signed the CLA.
author_association: NONE
user: login: CLAassistant
created_at: 2025-04-15T15:17:04Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/grafana/synthetic-monitoring-app?pullRequest=1119) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our [Contributor License Agreement](https://cla-assistant.io/grafana/synthetic-monitoring-app?pullRequest=1119) before we can accept your contribution.<br/>**1** out of **2** committers have signed the CLA.<br/><br/>:white_check_mark: ckbedwell<br/>:x: sm-release-app[bot]<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/grafana/synthetic-monitoring-app?pullRequest=1119) it.</sub>
author_association: NONE
user: login: ckbedwell
created_at: 2025-04-17T08:04:07Z
body: > For the analytics docs automation, I was thinking we could:
> 
> * Add a pre-commit hook that checks if there are changes and re-generates the file if so (maybe optional)
> * Run a task that, before building, compares the generated docs with the committed docs and fails if there are differences.
> 
> How does that sound?

I have a bit of reluctance adding a pre-commit hook because they inevitably get turned off or ignored at some point as they make a fairly large difference to the dev experience despite thinking 'what's a few seconds per commit?'. I think your second idea would work, maybe adding it as a GitHub workflow action? It would essentially be a lint check.

author_association: CONTRIBUTOR