From 0cf9e2cb3a9bd4f961ed7a5539d2610c8eae4bda Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 24 Apr 2026 14:53:21 +0200 Subject: [PATCH 1/3] Add a plugin analytics instrumentation skill to well-known skills Signed-off-by: Eric Peterson --- docs/.well-known/skills/index.json | 5 + .../plugin-analytics-instrumentation/SKILL.md | 185 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md diff --git a/docs/.well-known/skills/index.json b/docs/.well-known/skills/index.json index e66c7ea791..650166f944 100644 --- a/docs/.well-known/skills/index.json +++ b/docs/.well-known/skills/index.json @@ -19,6 +19,11 @@ "name": "plugin-full-frontend-system-migration", "description": "Fully migrate a Backstage plugin to the new frontend system, dropping all old system support. Use this skill for internal plugins that only need to run in a single app, or when you are ready to remove backward compatibility entirely.", "files": ["SKILL.md"] + }, + { + "name": "plugin-analytics-instrumentation", + "description": "Instrument a Backstage frontend plugin with analytics events using the Backstage Analytics API. Use this skill when adding, reviewing, or extending event capture (captureEvent, AnalyticsContext) in plugin components, deciding whether an interaction warrants an event, or writing tests for analytics behavior.", + "files": ["SKILL.md"] } ] } diff --git a/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md b/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md new file mode 100644 index 0000000000..e435544b45 --- /dev/null +++ b/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md @@ -0,0 +1,185 @@ +--- +name: plugin-analytics-instrumentation +description: Instrument a Backstage frontend plugin with analytics events using the Backstage Analytics API. Use this skill when adding, reviewing, or extending event capture (`captureEvent`, `AnalyticsContext`) in plugin components, deciding whether an interaction warrants an event, or writing tests for analytics behavior. +--- + +# Plugin Analytics Instrumentation Skill + +This skill helps you add analytics instrumentation to a Backstage frontend plugin so that app integrators can measure how the plugin is used. + +## Guiding principles + +Follow these before writing a single `captureEvent` call. + +### 1. Less is more — instrument semantic events, not every interaction + +Capture events that represent things **your plugin is semantically responsible for** — the domain actions only your plugin knows how to describe. Events should reflect **user intent** (something a person chose to do), not the lifecycle of your UI. Avoid instrumenting generic UI noise that the framework or design system already handles. + +Good candidates for plugin-owned events: + +- A domain verb only your plugin performs (`deploy`, `create`, `merge`, `approve`, `trigger`, `refresh`, `rerun`). +- An outcome you uniquely know about (a search returning N results, a scaffolder template saving Y minutes, a task transitioning to a terminal state). +- A context-carrying interaction where the attributes matter (clicking a search result with its `rank` and `to` target). + +Poor candidates — avoid these: + +- Routine clicks on navigation links, buttons, tabs, menu items — these are covered by the `navigate` event and by built-in instrumentation in `@backstage/ui` (see next principle). +- Low-value UI state toggles (expanding a panel, opening a tooltip, hovering). +- Every field edit in a form — usually one `submit`-style event at the end captures the intent. +- Component lifecycle signals — mounts, unmounts, re-renders, effect firings, data fetches. These describe the machinery of the UI, not the user, and will fire in plenty of contexts the user never initiated (route prefetches, Suspense boundaries, tab switches). Narrow exceptions exist for terminal states the user _lands on_ (e.g. `not-found`). +- Events whose `action` and `subject` duplicate what is already captured upstream. + +If you can't answer the question _"what question does this event help someone answer?"_ in one sentence, it's probably best not add the event. + +### 2. Prefer `@backstage/ui` components — they already instrument clicks + +Components from `@backstage/ui` (BUI) have built-in click instrumentation wired to the Analytics API. As of today this includes at least `Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` row clicks — each fires a `click` event with the link text as the subject and the destination `to` as an attribute. + +Consequences: + +- If you render a `Link`/`ButtonLink` from `@backstage/ui`, you do **not** need to add a `click` event by hand. Doing so would produce duplicate events. +- If a plain `` or a MUI button handles a navigation or action that you care about analytically, migrate it to the BUI equivalent first (see the `mui-to-bui-migration` skill). You'll get the click event for free and can focus your manual instrumentation on plugin-specific actions. +- Manual `captureEvent('click', ...)` calls are reserved for cases where **no** BUI component fits — for example, clicks on a canvas, a custom widget, or a non-link element whose interaction needs tracking. + +#### Overriding the default event with `noTrack` + +Occasionally a BUI component is the right UI primitive but the default event it fires isn't the one you want — for example, the interaction has a domain-specific verb (`approve`, `rerun`) rather than a generic `click`, or the subject should be a stable identifier rather than the visible link text. In that case, pass `noTrack` to suppress the built-in event and fire your own from the click handler: + +```tsx +import { Link } from '@backstage/ui'; +import { useAnalytics } from '@backstage/frontend-plugin-api'; + +function ApproveLink({ requestId, href }: Props) { + const analytics = useAnalytics(); + return ( + analytics.captureEvent('approve', requestId)} + > + Approve + + ); +} +``` + +Reach for `noTrack` only when you're **replacing** the default event, not layering a second event on top of it. If both the default `click` and your custom event are useful, the custom one probably belongs on a different component or in a different handler. `noTrack` is available on all BUI components with built-in instrumentation (`Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` rows). + +### 3. Split events so analysis stays flexible + +An `AnalyticsEvent` has an `action`, a `subject`, and surrounding `context` (which is filled in with `pluginId` and `extension` automatically). Keep each dimension disaggregated so questions can be answered at any level of granularity. + +- **Action** is the verb — kept generic and reused across plugins (`click`, `search`, `filter`, `create`, `discover`). Avoid squashing what belongs in context into the action (e.g. don't use `filterEntityTable` — use `filter` and let the `extension` / `AnalyticsContext` identify the table). +- **Subject** is the noun — the specific thing acted upon (a PR name, a template name, a search term, a result title). +- **Attributes** are optional key/value dimensions available at capture time (`to`, `org`, `repo`, `entityRef`). +- **Context** is for metadata coming from further up the React tree, or shared across many events in a region. + +When in doubt about attribute naming, reuse what existing events in the repo use (e.g. `entityRef` for catalog entities, `to` for destinations, `searchTypes` for search). Consistency across plugins makes aggregation possible. + +## How to capture an event + +Get a tracker with `useAnalytics()` and call `captureEvent(action, subject, options?)`. + +```tsx +import { useAnalytics } from '@backstage/frontend-plugin-api'; + +function DeployButton({ serviceName }: { serviceName: string }) { + const analytics = useAnalytics(); + const handleDeploy = () => { + // ...perform the deploy + analytics.captureEvent('deploy', serviceName); + }; + return ; +} +``` + +For old-system plugins, the same hook is re-exported from `@backstage/core-plugin-api`; the behavior is identical. New plugins targeting the new frontend system should import from `@backstage/frontend-plugin-api`. + +### Adding `value` and `attributes` + +`value` is a single numeric metric associated with the event (duration, rank, count). `attributes` are dimensional string/number/boolean pairs. + +```tsx +analytics.captureEvent('merge', pullRequestName, { + value: pullRequestAgeInMinutes, + attributes: { org, repo }, +}); +``` + +Keep attributes flat and serializable. Don't stuff large objects or PII in here. + +### Using `AnalyticsContext` for ambient metadata + +When the same attribute applies to many events under a subtree — or when the metadata lives further up the tree than the component firing the event — wrap the subtree in an `` instead of passing props down: + +```tsx +import { AnalyticsContext } from '@backstage/frontend-plugin-api'; + +function TaskPage({ taskId, entityRef }: Props) { + return ( + + + + + ); +} +``` + +Every `captureEvent` fired inside that subtree will have `taskId` and `entityRef` merged into its `context`. Contexts nest and merge; inner values override outer ones. + +Good uses of `AnalyticsContext`: + +- Page- or route-level attributes that apply to every interaction on that page (`entityRef`, `taskId`, a tab selection). +- Cross-cutting aggregation keys that let app integrators group events (`segment`, `workspace`). + +Don't wrap every small component in its own context — prefer to set context once at the boundary where the metadata first becomes available. + +## Unit testing event capture + +Use `MockAnalyticsApi` from `@backstage/frontend-test-utils`. Prefer one thorough test with multiple assertions over many small ones. + +```tsx +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { analyticsApiRef } from '@backstage/frontend-plugin-api'; +import { + MockAnalyticsApi, + TestApiProvider, + wrapInTestApp, +} from '@backstage/frontend-test-utils'; + +it('captures a deploy event with the service name', async () => { + const analytics = new MockAnalyticsApi(); + + render( + wrapInTestApp( + + + , + ), + ); + + fireEvent.click(await screen.findByRole('button', { name: /deploy/i })); + + await waitFor(() => { + expect(analytics.getEvents()[0]).toMatchObject({ + action: 'deploy', + subject: 'payments-api', + }); + }); +}); +``` + +Assert on `action`, `subject`, and any `attributes`/`value` you explicitly set. Don't assert on auto-populated context keys like `pluginId` — those are the framework's responsibility. + +## Review checklist + +Before submitting instrumentation changes: + +1. [ ] Every new `captureEvent` call represents a **plugin-semantic, user-initiated** action (not a click already covered by BUI, a navigation, or a component-lifecycle trigger). +2. [ ] Route to a BUI component (`Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, `Table`) wherever one fits, rather than instrumenting a plain element by hand. +3. [ ] `action` is a short generic verb; plugin/extension identity is left to the auto-populated `context`. +4. [ ] Attribute keys reuse established conventions where applicable (`entityRef`, `to`, `searchTypes`, etc.). +5. [ ] Shared attributes are set via a single `` at a boundary, not duplicated across events. +6. [ ] `value` is numeric and meaningful (duration, rank, count) — not a stand-in for a string dimension. +7. [ ] No PII, secrets, tokens, or large serialized payloads in attributes. +8. [ ] At least one unit test covers each new event using `MockAnalyticsApi`. From 774c29f12d53dca2980f6f89e0c7e58b6fe4de16 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 24 Apr 2026 14:53:45 +0200 Subject: [PATCH 2/3] Backport new hints from agent skill to human documentation Signed-off-by: Eric Peterson --- docs/frontend-system/building-plugins/08-analytics.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/frontend-system/building-plugins/08-analytics.md b/docs/frontend-system/building-plugins/08-analytics.md index 0b51b5ae1a..e71f1ee2b8 100644 --- a/docs/frontend-system/building-plugins/08-analytics.md +++ b/docs/frontend-system/building-plugins/08-analytics.md @@ -177,6 +177,16 @@ const analytics = useAnalytics(); analytics.captureEvent('deploy', serviceName); ``` +The events you capture should reflect user intent and domain actions your +plugin is uniquely responsible for, rather than generic clicks or UI +lifecycle events. Many `@backstage/ui` components (such as `Link`, +`ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` rows) already capture +`click` events automatically, so you rarely need to instrument +navigation-style clicks by hand. If one of those components is the right UI +primitive but the default event is not what you want to capture, pass the +`noTrack` prop to suppress it and call `captureEvent` from your own click +handler instead. + ### Providing Extra Attributes Additional dimensional `attributes` as well as a numeric `value` can be provided From 2f6d3ed2f97c0c6564b8c0acc7e5075fe78bcfff Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 24 Apr 2026 15:13:18 +0200 Subject: [PATCH 3/3] Address review feedback Signed-off-by: Eric Peterson --- .../skills/plugin-analytics-instrumentation/SKILL.md | 10 +++++----- docs/frontend-system/building-plugins/08-analytics.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md b/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md index e435544b45..543a3cc90c 100644 --- a/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md +++ b/docs/.well-known/skills/plugin-analytics-instrumentation/SKILL.md @@ -29,11 +29,11 @@ Poor candidates — avoid these: - Component lifecycle signals — mounts, unmounts, re-renders, effect firings, data fetches. These describe the machinery of the UI, not the user, and will fire in plenty of contexts the user never initiated (route prefetches, Suspense boundaries, tab switches). Narrow exceptions exist for terminal states the user _lands on_ (e.g. `not-found`). - Events whose `action` and `subject` duplicate what is already captured upstream. -If you can't answer the question _"what question does this event help someone answer?"_ in one sentence, it's probably best not add the event. +If you can't answer the question _"what question does this event help someone answer?"_ in one sentence, it's probably best not to add the event. ### 2. Prefer `@backstage/ui` components — they already instrument clicks -Components from `@backstage/ui` (BUI) have built-in click instrumentation wired to the Analytics API. As of today this includes at least `Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` row clicks — each fires a `click` event with the link text as the subject and the destination `to` as an attribute. +Components from `@backstage/ui` (BUI) have built-in click instrumentation wired to the Analytics API. As of today this includes at least `Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` row clicks. When these components are used for navigation (i.e. rendered with an `href`), a `click` event is fired with the destination included as a `to` attribute. For most of them the `subject` is a best-effort human-readable label — the `aria-label`, the visible text, or the `href` as a fallback. `Table` rows are the exception: their `subject` is the `href` string itself, not derived from visible row content. Consequences: @@ -136,19 +136,19 @@ Don't wrap every small component in its own context — prefer to set context on ## Unit testing event capture -Use `MockAnalyticsApi` from `@backstage/frontend-test-utils`. Prefer one thorough test with multiple assertions over many small ones. +Use `mockApis.analytics()` from `@backstage/frontend-test-utils` — it returns a mock `AnalyticsApi` implementation with a `getEvents()` helper for assertions. Prefer one thorough test with multiple assertions over many small ones. ```tsx import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import { analyticsApiRef } from '@backstage/frontend-plugin-api'; import { - MockAnalyticsApi, + mockApis, TestApiProvider, wrapInTestApp, } from '@backstage/frontend-test-utils'; it('captures a deploy event with the service name', async () => { - const analytics = new MockAnalyticsApi(); + const analytics = mockApis.analytics(); render( wrapInTestApp( diff --git a/docs/frontend-system/building-plugins/08-analytics.md b/docs/frontend-system/building-plugins/08-analytics.md index e71f1ee2b8..9a4819f282 100644 --- a/docs/frontend-system/building-plugins/08-analytics.md +++ b/docs/frontend-system/building-plugins/08-analytics.md @@ -180,7 +180,7 @@ analytics.captureEvent('deploy', serviceName); The events you capture should reflect user intent and domain actions your plugin is uniquely responsible for, rather than generic clicks or UI lifecycle events. Many `@backstage/ui` components (such as `Link`, -`ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` rows) already capture +`ButtonLink`, `Tab`, `MenuItem`, `Tag`, and `Table` rows) often capture `click` events automatically, so you rarely need to instrument navigation-style clicks by hand. If one of those components is the right UI primitive but the default event is not what you want to capture, pass the