Imported from modem-dev/ossrules (
public/files/grafana/public/app/core/journeys/AGENTS.md). Install upstream withnpx skills add modem-dev/ossrules --skill journeys. Copyright stays with the author.
CUJ instrumentation - Agent Configuration
This directory holds the runtime wirings for Critical User Journeys (CUJs) — multi-step user workflows tracked end-to-end as OTel traces + Faro measurements behind the cujTracking feature toggle.
Required Reading
Always read these before adding or modifying a journey:
./journey-tracking.md- the canonical reference. It covers architecture, telemetry shape, the registry, parent journeys, debug logging, and a full worked example../searchToResource.ts- canonical wiring file; copy this shape for new journeys../searchToResource.test.ts- canonical test shape../__test-utils__/journeyTestHarness.ts- the only legitimate way to mock the tracker in unit tests.
Public framework types live in @grafana/runtime (packages/grafana-runtime/src/services/JourneyTracker.ts):
registerJourneyTriggers- registers the start condition (called once at module import).onJourneyInstance- registers the per-instance end-condition handler (also called once at module import).JourneyMeta- registry entry (type, description, owner, timeoutMs, optionalparents).JourneyHandle- per-instance handle:recordEvent,startStep,setAttributes,end(outcome).
Never import from JourneyTrackerImpl or JourneyRegistryImpl directly - those are internal.
Adding a New Journey: Recipe
Fast path:
yarn cuj:new <type> [--with-smoke]scaffolds the wiring file, test file, optional smoke driver, registry entry, and bootstrap import in one shot. Runyarn cuj:new --helpfor flags (--owner,--description,--timeout-ms,--parent,--dry-run). After scaffolding, fill in the TODOs marked in the generated files. The steps below describe what the script generates and why.
This is the short version of journey-tracking.md Steps 0-7. Read the full version if anything's unclear.
1. Decide the journey shape
- Type name:
snake_caseverb-object (alert_rule_save,panel_edit). - Owner: the squad whose telemetry this is (
grafana-dashboards,grafana-alerting, …). - Timeout: how long is "still going" plausible for? Default 5 min; multi-hour flows (datasource setup) use longer.
- Parents (optional): other journey types that should nest under (set
parents: ['parent_type']). When the parent is active at start, the child's span nests in the parent's trace.
2. Identify or add interactions
The framework subscribes to reportInteraction events via onInteraction(name, callback). Check what the relevant code already emits:
grep -rn 'reportInteraction(' public/app/features/<your-area>/
If the events you need don't exist, add them. Use silent: true for new pure-CUJ events that shouldn't pollute analytics:
reportInteraction('grafana_<area>_<verb>', { ...attrs }, { silent: true });
3. Register metadata
Add an entry to journeyRegistry.ts:
{
type: 'alert_rule_save',
description: 'User edits and saves an alert rule',
owner: 'grafana-alerting',
timeoutMs: 10 * 60_000,
// parents: ['some_parent_type'], // optional
},
4. Create the wiring file
Path: public/app/core/journeys/<camelCase>.ts. Follow searchToResource.ts exactly:
import { onInteraction, registerJourneyTriggers, onJourneyInstance } from '@grafana/runtime';
import { collectUnsubs, str } from './utils';
/**
* Journey: <type>
* <one-line description of what the journey covers>
*
* Start triggers: <which interaction(s) start it>
* Steps (duration): <list> // optional
* Events (point-in-time): <list> // optional
* End conditions:
* - success: <which interaction>
* - discarded / canceled / abandoned: <which interaction>
* - timeout: 60s / 5min / etc.
*/
registerJourneyTriggers('<type>', (tracker) => {
return onInteraction('<start_event>', (props) => {
if (!tracker.getActiveJourney('<type>')) {
tracker.startJourney('<type>', { attributes: { ... } });
}
});
});
onJourneyInstance('<type>', (handle) => {
const { add, cleanup } = collectUnsubs();
// wire steps + end conditions; each onInteraction handler call goes through `add(...)`
return cleanup;
});
Use the str(value) helper for any value going into attributes - it coerces undefined / objects to a safe string.
5. Import at bootstrap
Add the import to public/app/app.ts:
await Promise.all([
// ...existing imports...
import('./core/journeys/<camelCase>'),
]);
6. Write tests
Path: public/app/core/journeys/<camelCase>.test.ts. Copy the shape of searchToResource.test.ts. Cover:
- start condition fires with right attributes
- each step / event handler fires correctly
- each end condition (success, discarded, etc.) ends the journey with the right outcome
- doesn't double-start when journey is active
- ignores irrelevant interactions
Run: yarn jest --no-watch <camelCase>.test.ts.
7. Verify locally
Enable the toggle + Faro (see journey-tracking.md Configuration section). Walk the workflow with localStorage.setItem('grafana.debug.journeyTracker', 'true') set. Confirm in console: startJourney, step events, end with right outcome.
For automated load: see ./searchToResource.smoke.ts for the optional smoke driver pattern (runs the journey N times via Playwright).
Pre-merge Checklist
- Registry entry has owner, description, sensible
timeoutMs. - Wiring file follows the
searchToResource.tsshape (no module-scopeStepHandlethat outlives a journey - keep duration-step bookkeeping insideonJourneyInstance's closure). - All
onInteractionsubscriptions insideonJourneyInstanceare tracked throughcollectUnsubsso cleanup runs on journey end. - Tests cover start, every end condition, and at least one negative case.
- Bootstrap import added to
app.ts. - If you added new
reportInteractioncalls purely for CUJ purposes, they pass{ silent: true }. - If parent nesting is intended, parent's
typelisted inparents: [...]. - PR titles + commits scoped to the squad area, not pan-CUJ.
Common Mistakes
- Module-scope step handles. Causes step leak across journey instances. Always store
StepHandlein theonJourneyInstanceclosure. - Forgetting
add(...)around anonInteractionsubscription. The unsubscribe is lost; subscriptions outlive the journey. - Using
recordEventfor things that have a duration. UsestartStep+step.end()for measured operations;recordEventis point-in-time. - Not handling the discarded path. A journey that only ends on success will time out for the abandoned case - explicitly map "user closed without selecting" to
handle.end('discarded'). - Polluting analytics. New CUJ-only events should be
silent: true. Existing analytics events (command_palette_action_selected, etc.) stay un-silent because they have independent value.
Smoke Driver (optional)
Each journey can ship a Playwright smoke driver that exercises it against local Grafana. Pattern: a <camelCase>.smoke.ts file alongside the wiring that exports a JourneyDriver. Shared helpers (typing patterns, activation styles, palette open) live in ./__smoke__/. The orchestrator in scripts/cuj-smoke.ts imports and registers each driver. See searchToResource.smoke.ts for the canonical example.
Smoke files import each other with explicit .ts extensions (Node ESM requirement) and live under their own tsconfig.smoke.json. Validate with yarn typecheck:smoke (also runs in CI + lefthook on relevant file changes).